From a2a3ffe5eba58b1378ab0fa19aa5063d4425f542 Mon Sep 17 00:00:00 2001 From: Chris Portscheller Date: Sun, 19 Jul 2026 18:06:56 -0500 Subject: [PATCH] =?UTF-8?q?chore:=20consolidation=20&=20cleanup=20?= =?UTF-8?q?=E2=80=94=20detector,=20honeypot,=20dead=20cron,=20allowlist=20?= =?UTF-8?q?(#15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Single detector path: early_check now runs through the WebDecoy_Detector wrapper (SDK BotDetector + WP signals) — the same one WooCommerce uses — instead of the raw SDK detector. Score-neutral (the SDK ignores the extra WP signals; request_path was already added). - Removed the dead WebDecoy_Forms class: it was loaded but never instantiated (fully unwired), and its daily-rotation idea already lives in the honeytoken feature. The live inline honeypot remains the single honeypot implementation. - Removed the dead webdecoy_sync_blocked_ips cron (scheduled but never had a handler); existing installs get it cleared on upgrade and (de)activation. - IP allowlist: is_allowlisted() existed but nothing wrote ip_allowlist and nothing called it. Added a Blocking-tab field (IPs/CIDRs, validated on save) and wired the check into early_check so allowlisted IPs bypass all detection. - (SDK User-Agent staleness was already fixed under #1.) Verified: allowlist exact + CIDR matching, no dangling WebDecoy_Forms references, full suite green (41). Closes #15. Part of #16. Co-authored-by: Claude --- admin/partials/settings-page.php | 11 + changelog.txt | 2 + includes/class-webdecoy-activator.php | 11 +- includes/class-webdecoy-forms.php | 577 -------------------------- webdecoy.php | 24 +- 5 files changed, 34 insertions(+), 591 deletions(-) delete mode 100644 includes/class-webdecoy-forms.php diff --git a/admin/partials/settings-page.php b/admin/partials/settings-page.php index 04c6a5d..3a1fa5f 100644 --- a/admin/partials/settings-page.php +++ b/admin/partials/settings-page.php @@ -463,6 +463,17 @@

+ + + +
+ + + +

+
diff --git a/changelog.txt b/changelog.txt index 4452094..d046c1f 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,6 +1,8 @@ *** WebDecoy Bot Detection Changelog *** = 2.2.0 - Unreleased = +* Added: IP allowlist — IPs or CIDR ranges that bypass all detection and blocking (Settings → Blocking). Previously the check existed internally but had no way to configure it. +* Changed: Consolidated onto a single detector path (SDK detector + WordPress-signal wrapper) across front-end, forms, and WooCommerce; removed the unused legacy forms class and a dead scheduled task. No behavior change to detection. * Added: Filter rules — write expression-based rules (e.g. `ip.tor or ip.abuse_score > 50`, `ip.country in ["CN","RU"] and req.path matches "^/wp-login"`) evaluated before scoring; block or throttle, with dry-run. New Settings → Rules tab with a rule builder and parse-on-save validation. Expression language is byte-for-byte compatible with @webdecoy/node. * Added: IP enrichment — VPN/proxy/Tor, geo, ASN, and abuse-score data (WebDecoy Cloud) powering the ip.* filter-rule fields, cached 1 hour, fetched only when a rule needs it, fail-open. * Improved: Rate limiting now runs as a rule in the engine — over-limit requests get a proper 429 with Retry-After and X-RateLimit-* headers (previously it only nudged the bot score). Adds a sliding-window algorithm (exact via a persistent object cache, falling back to the fixed-window database counter), per-IP / per-IP+route / per-user keying, and dry-run. diff --git a/includes/class-webdecoy-activator.php b/includes/class-webdecoy-activator.php index 3f946ee..4909912 100644 --- a/includes/class-webdecoy-activator.php +++ b/includes/class-webdecoy-activator.php @@ -207,17 +207,16 @@ private static function schedule_cleanup(): void wp_schedule_event(time(), 'hourly', 'webdecoy_cleanup_expired'); } - // Schedule blocked IP sync (every 15 minutes) - if (!wp_next_scheduled('webdecoy_sync_blocked_ips')) { - wp_schedule_event(time(), 'fifteen_minutes', 'webdecoy_sync_blocked_ips'); - } - // Safety-net drain of the violation-report spool (every 15 minutes). // The primary delivery path is the per-request shutdown flush; this // catches anything left behind after an ingest outage. if (!wp_next_scheduled('webdecoy_flush_violations')) { wp_schedule_event(time(), 'fifteen_minutes', 'webdecoy_flush_violations'); } + + // Note: webdecoy_sync_blocked_ips is intentionally NOT scheduled — it + // never had a handler. Any leftover schedule from an older install is + // cleared in activate()/deactivate(). } /** @@ -230,6 +229,8 @@ public static function maybe_upgrade(): void if (version_compare($current_version, self::DB_VERSION, '<')) { self::create_tables(); update_option('webdecoy_db_version', self::DB_VERSION); + // Clear the dead sync-blocked-ips schedule left by older installs. + wp_clear_scheduled_hook('webdecoy_sync_blocked_ips'); } } diff --git a/includes/class-webdecoy-forms.php b/includes/class-webdecoy-forms.php deleted file mode 100644 index 0b4e4d0..0000000 --- a/includes/class-webdecoy-forms.php +++ /dev/null @@ -1,577 +0,0 @@ -options = $options ?? get_option('webdecoy_options', []); - $this->daily_seed = date('Ymd') . wp_salt('auth'); - $this->generate_honeypot_names(); - $this->generate_honeypot_classes(); - } - - /** - * Generate random honeypot field names that look legitimate - */ - private function generate_honeypot_names(): void - { - $contexts = ['comment', 'login', 'register', 'contact']; - - foreach ($contexts as $context) { - // Generate a deterministic but random-looking field name - $hash = md5($this->daily_seed . $context); - - // Select prefix and suffix based on hash - $prefixIndex = hexdec(substr($hash, 0, 2)) % count(self::FIELD_PREFIXES); - $suffixIndex = hexdec(substr($hash, 2, 2)) % count(self::FIELD_SUFFIXES); - - $prefix = self::FIELD_PREFIXES[$prefixIndex]; - $suffix = self::FIELD_SUFFIXES[$suffixIndex]; - - // Add a short random segment to ensure uniqueness - $randomSegment = substr($hash, 4, 4); - - // Create names that look like legitimate form fields - $this->honeypot_names[$context] = $prefix . '_' . $suffix . '_' . $randomSegment; - } - } - - /** - * Generate random CSS class names - */ - private function generate_honeypot_classes(): void - { - $contexts = ['comment', 'login', 'register', 'contact']; - - foreach ($contexts as $context) { - $hash = md5($this->daily_seed . 'class_' . $context); - - // Generate wrapper class - $wrapperPrefixIndex = hexdec(substr($hash, 0, 2)) % count(self::CLASS_PREFIXES); - $wrapperPrefix = self::CLASS_PREFIXES[$wrapperPrefixIndex]; - $wrapperSuffix = substr($hash, 2, 6); - - // Generate field class - $fieldPrefixIndex = hexdec(substr($hash, 8, 2)) % count(self::CLASS_PREFIXES); - $fieldPrefix = self::CLASS_PREFIXES[$fieldPrefixIndex]; - $fieldSuffix = substr($hash, 10, 6); - - $this->honeypot_classes[$context] = [ - 'wrapper' => $wrapperPrefix . '-group-' . $wrapperSuffix, - 'field' => $fieldPrefix . '-input-' . $fieldSuffix, - ]; - } - } - - /** - * Get a random label text for honeypot - * - * @param string $context Form context - * @return string - */ - private function get_honeypot_label(string $context): string - { - $hash = md5($this->daily_seed . 'label_' . $context); - $index = hexdec(substr($hash, 0, 2)) % count(self::LABEL_TEXTS); - return self::LABEL_TEXTS[$index]; - } - - /** - * Get randomized hiding CSS styles - * - * @param string $context Form context - * @return string - */ - private function get_hiding_styles(string $context): string - { - $hash = md5($this->daily_seed . 'style_' . $context); - $variant = hexdec(substr($hash, 0, 2)) % 5; - - // Different hiding techniques that are harder to detect - $styles = [ - // Off-screen positioning with random values - 'position:absolute;left:-' . (9000 + (hexdec(substr($hash, 2, 3)) % 1000)) . 'px;top:-' . (9000 + (hexdec(substr($hash, 5, 3)) % 1000)) . 'px;', - // Zero dimensions with clip - 'position:absolute;width:0;height:0;padding:0;margin:0;overflow:hidden;clip:rect(0,0,0,0);border:0;', - // Opacity and pointer-events (modern) - 'opacity:0;position:absolute;top:0;left:0;height:0;width:0;z-index:-1;pointer-events:none;', - // Transform off-screen - 'position:absolute;transform:translateX(-' . (10000 + (hexdec(substr($hash, 8, 3)) % 500)) . 'px);', - // Visibility hidden with off-screen - 'visibility:hidden;position:absolute;left:-100vw;top:-100vh;', - ]; - - return $styles[$variant]; - } - - /** - * Get honeypot field name for a context - * - * @param string $context Form context - * @return string Field name - */ - public function get_honeypot_name(string $context): string - { - return $this->honeypot_names[$context] ?? 'webdecoy_hp_' . $context; - } - - /** - * Render honeypot field HTML - * - * @param string $context Form context - * @return string HTML - */ - public function render_honeypot(string $context): string - { - $name = $this->get_honeypot_name($context); - $classes = $this->honeypot_classes[$context] ?? [ - 'wrapper' => 'form-group-' . substr(md5($context), 0, 6), - 'field' => 'input-field-' . substr(md5($context), 6, 6), - ]; - $label = $this->get_honeypot_label($context); - $styles = $this->get_hiding_styles($context); - - // Generate a unique ID that looks legitimate - $fieldId = $classes['field'] . '-' . substr(md5($this->daily_seed . $name), 0, 4); - - // Build HTML that looks like a normal form field - $html = ''; - - return $html; - } - - /** - * Output honeypot field - * - * @param string $context Form context - */ - public function output_honeypot(string $context): void - { - // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- render_honeypot returns safe HTML - echo $this->render_honeypot($context); - } - - /** - * Check if honeypot was triggered - * - * @param string $context Form context - * @return bool True if honeypot triggered (bot detected) - */ - public function check_honeypot(string $context): bool - { - $name = $this->get_honeypot_name($context); - - // Check POST data for current day's honeypot name - if (isset($_POST[$name]) && !empty($_POST[$name])) { - return true; - } - - // Check previous day's name (in case form was loaded before midnight) - $previousDaySeed = date('Ymd', strtotime('-1 day')) . wp_salt('auth'); - $previousName = $this->generate_name_for_seed($previousDaySeed, $context); - if (isset($_POST[$previousName]) && !empty($_POST[$previousName])) { - return true; - } - - return false; - } - - /** - * Generate honeypot name for a specific seed - * - * @param string $seed Daily seed - * @param string $context Form context - * @return string Field name - */ - private function generate_name_for_seed(string $seed, string $context): string - { - $hash = md5($seed . $context); - $prefixIndex = hexdec(substr($hash, 0, 2)) % count(self::FIELD_PREFIXES); - $suffixIndex = hexdec(substr($hash, 2, 2)) % count(self::FIELD_SUFFIXES); - $prefix = self::FIELD_PREFIXES[$prefixIndex]; - $suffix = self::FIELD_SUFFIXES[$suffixIndex]; - $randomSegment = substr($hash, 4, 4); - return $prefix . '_' . $suffix . '_' . $randomSegment; - } - - /** - * Validate form submission - * - * @param string $context Form context - * @return array ['valid' => bool, 'reason' => string|null] - */ - public function validate_submission(string $context): array - { - // Check honeypot if enabled - if ($this->options['inject_honeypot'] && $this->check_honeypot($context)) { - return [ - 'valid' => false, - 'reason' => 'honeypot_triggered', - ]; - } - - // Run bot detection - $detector = new WebDecoy_Detector($this->options); - $result = $detector->analyze(); - - if ($result->shouldBlock($this->options['min_score_to_block'])) { - return [ - 'valid' => false, - 'reason' => 'bot_detected', - 'score' => $result->getScore(), - 'flags' => $result->getFlags(), - ]; - } - - return ['valid' => true, 'reason' => null]; - } - - /** - * Handle invalid form submission - * - * @param string $context Form context - * @param array $validation Validation result - */ - public function handle_invalid(string $context, array $validation): void - { - $ip = (new \WebDecoy\SignalCollector(WebDecoy_Plugin::instance()->get_trusted_proxies()))->getIP(); - - // Block the IP - $blocker = new WebDecoy_Blocker(); - $duration = $this->options['block_duration'] > 0 ? $this->options['block_duration'] : null; - $reason = sprintf('%s form: %s', $context, $validation['reason']); - - if ($validation['reason'] === 'honeypot_triggered') { - // Honeypot = definite bot, longer block - $blocker->block($ip, $reason, $duration ? $duration * 2 : $duration); - } else { - $blocker->block($ip, $reason, $duration); - } - - // Log detection - $this->log_detection($ip, $context, $validation); - } - - /** - * Log detection to database and forward to WebDecoy - * - * @param string $ip - * @param string $context - * @param array $validation - */ - private function log_detection(string $ip, string $context, array $validation): void - { - global $wpdb; - - $table = $wpdb->prefix . 'webdecoy_detections'; - $score = $validation['score'] ?? ($validation['reason'] === 'honeypot_triggered' ? 100 : 75); - $source = 'form_' . $context; - $flags = $validation['flags'] ?? [$validation['reason']]; - - // Log locally - $wpdb->insert($table, [ - 'ip_address' => $ip, - 'user_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field($_SERVER['HTTP_USER_AGENT']) : '', - 'score' => $score, - 'threat_level' => 'HIGH', - 'source' => $source, - 'flags' => json_encode($flags), - 'created_at' => current_time('mysql'), - ]); - - // Forward to WebDecoy ingest service - $this->forward_to_webdecoy($ip, $source, $score, $flags); - } - - /** - * Forward detection to WebDecoy ingest service - * - * @param string $ip - * @param string $source - * @param int $score - * @param array $flags - */ - private function forward_to_webdecoy(string $ip, string $source, int $score, array $flags): void - { - // Check if API is configured - if (empty($this->options['api_key']) || empty($this->options['organization_id'])) { - return; - } - - $ingest_url = rtrim($this->options['api_url'] ?? 'https://api.webdecoy.com', '/'); - $ingest_url = str_replace('api.webdecoy.com', 'ingest.webdecoy.com', $ingest_url); - $ingest_url .= '/api/v1/detect'; - - $collector = new \WebDecoy\SignalCollector(WebDecoy_Plugin::instance()->get_trusted_proxies()); - - $payload = [ - 'aid' => $this->options['organization_id'], - 'sid' => $this->options['scanner_id'] ?? ('wordpress-forms-' . get_site_url()), - 'v' => 1, - 's' => $score, - 'f' => $flags, - 'fp' => [ - 'userAgent' => $collector->getUserAgent(), - 'ip' => $ip, - ], - 'url' => $collector->getCurrentUrl(), - 'ref' => isset($_SERVER['HTTP_REFERER']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_REFERER'])) : '', // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized - 'ts' => time() * 1000, - 'source' => $source, - 'blocked' => true, - ]; - - // Send to ingest (fire-and-forget) - wp_remote_post($ingest_url, [ - 'timeout' => 1, - 'blocking' => false, - 'headers' => [ - 'Content-Type' => 'application/json', - 'Authorization' => 'Bearer ' . $this->options['api_key'], - ], - 'body' => json_encode($payload), - ]); - } - - /** - * Add honeypot to comment form - */ - public function add_comment_honeypot(): void - { - $this->output_honeypot('comment'); - } - - /** - * Add honeypot to login form - */ - public function add_login_honeypot(): void - { - $this->output_honeypot('login'); - } - - /** - * Add honeypot to registration form - */ - public function add_register_honeypot(): void - { - $this->output_honeypot('register'); - } - - /** - * Validate comment - * - * @param array $commentdata - * @return array - */ - public function validate_comment(array $commentdata): array - { - if (!$this->options['protect_comments']) { - return $commentdata; - } - - $validation = $this->validate_submission('comment'); - - if (!$validation['valid']) { - $this->handle_invalid('comment', $validation); - - wp_die( - esc_html__('Your comment was blocked due to suspicious activity.', 'webdecoy'), - esc_html__('Comment Blocked', 'webdecoy'), - ['response' => 403, 'back_link' => true] - ); - } - - return $commentdata; - } - - /** - * Validate login - * - * @param \WP_User|\WP_Error|null $user - * @param string $username - * @param string $password - * @return \WP_User|\WP_Error|null - */ - public function validate_login($user, string $username, string $password) - { - if (!$this->options['protect_login']) { - return $user; - } - - // Skip if already an error or empty credentials - if (is_wp_error($user) || empty($username)) { - return $user; - } - - $validation = $this->validate_submission('login'); - - if (!$validation['valid']) { - $this->handle_invalid('login', $validation); - - return new \WP_Error( - 'webdecoy_blocked', - __('Login blocked due to suspicious activity.', 'webdecoy') - ); - } - - return $user; - } - - /** - * Validate registration - * - * @param string $sanitized_user_login - * @param string $user_email - * @param \WP_Error $errors - */ - public function validate_registration(string $sanitized_user_login, string $user_email, \WP_Error $errors): void - { - if (!$this->options['protect_registration']) { - return; - } - - $validation = $this->validate_submission('register'); - - if (!$validation['valid']) { - $this->handle_invalid('register', $validation); - - $errors->add( - 'webdecoy_blocked', - __('Registration blocked due to suspicious activity.', 'webdecoy') - ); - } - } - - /** - * Get form protection stats - * - * @param int $days Number of days - * @return array - */ - public function get_stats(int $days = 7): array - { - global $wpdb; - - $table = $wpdb->prefix . 'webdecoy_detections'; - $since = date('Y-m-d H:i:s', strtotime("-{$days} days")); - - // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.LikeWildcardsInQuery -- Static pattern for form sources - $by_form = $wpdb->get_results($wpdb->prepare( - "SELECT source, COUNT(*) as count FROM {$table} - WHERE created_at > %s AND source LIKE 'form_%' - GROUP BY source", - $since - ), OBJECT_K); - - // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.LikeWildcardsInQuery -- Static pattern for form sources - $honeypot_triggers = $wpdb->get_var($wpdb->prepare( - "SELECT COUNT(*) FROM {$table} - WHERE created_at > %s AND score = 100 AND source LIKE 'form_%'", - $since - )); - - return [ - 'by_form' => $by_form, - 'honeypot_triggers' => (int) $honeypot_triggers, - 'period_days' => $days, - ]; - } -} diff --git a/webdecoy.php b/webdecoy.php index ff63430..c580624 100644 --- a/webdecoy.php +++ b/webdecoy.php @@ -197,6 +197,7 @@ private function load_options(): void 'custom_allowlist' => [], // Blocking Settings + 'ip_allowlist' => [], // IPs/CIDRs that bypass all detection 'block_action' => 'block', 'block_duration' => 24, 'show_block_page' => true, @@ -762,7 +763,6 @@ public function load_includes(): void require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-activator.php'; require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-blocker.php'; require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-detector.php'; - require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-forms.php'; require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-rate-limiter.php'; require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-pow.php'; @@ -824,6 +824,11 @@ public function early_check(): void $blocker = new WebDecoy_Blocker(); $ip = $this->get_client_ip(); + // Allowlisted IPs bypass all detection entirely. + if ($blocker->is_allowlisted($ip)) { + return; + } + if ($blocker->is_blocked($ip)) { $this->block_request(__('Your IP has been blocked.', 'webdecoy')); return; @@ -863,14 +868,11 @@ public function early_check(): void // 429 + Retry-After + X-RateLimit-* headers), so it evaluates in order // with tripwires and filters rather than as a separate pre-check. - // Run bot detection with request path for MITRE ATT&CK path analysis - $detector = $this->get_detector(); - $signals = $detector->getSignalCollector()->collect(); - - // Add request path for path-based scoring - $signals['request_path'] = $this->get_request_path(); - - $result = $detector->analyze($signals); + // Run bot detection through the WebDecoy_Detector wrapper — the single + // detector path (SDK BotDetector + WP-specific signals), the same one + // WooCommerce uses. It collects request signals, adds the request path + // for MITRE ATT&CK path analysis, and merges WP context. + $result = (new WebDecoy_Detector($this->options))->analyze(); // Skip if good bot if ($result->isGoodBot()) { @@ -1792,6 +1794,10 @@ public function sanitize_options(array $input): array $sanitized['custom_allowlist'] = array_filter(array_map('sanitize_text_field', explode("\n", $input['custom_allowlist'] ?? ''))); // Blocking Settings + // Allowlist: validate as IPs/CIDRs (reuses the trusted-proxy validator), + // stored as an array of valid entries. + $allowlist = $this->sanitize_trusted_proxies($input['ip_allowlist'] ?? ''); + $sanitized['ip_allowlist'] = $allowlist === '' ? [] : explode("\n", $allowlist); $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['show_block_page'] = !empty($input['show_block_page']);