diff --git a/admin/partials/blocked-ips-page.php b/admin/partials/blocked-ips-page.php index 2c64c36..401eef6 100644 --- a/admin/partials/blocked-ips-page.php +++ b/admin/partials/blocked-ips-page.php @@ -19,8 +19,9 @@ // Handle actions if (isset($_POST['webdecoy_block_ip']) && isset($_POST['_wpnonce']) && wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_wpnonce'])), 'webdecoy_block_ip') && current_user_can('manage_options')) { - $ip = sanitize_text_field($_POST['ip_address']); - $reason = sanitize_text_field($_POST['reason'] ?? ''); + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- presence check only; the value is validated below after nonce and capability checks + $ip = sanitize_text_field(wp_unslash($_POST['ip_address'])); + $reason = sanitize_text_field(wp_unslash($_POST['reason'] ?? '')); $duration = intval($_POST['duration'] ?? 24); if (filter_var($ip, FILTER_VALIDATE_IP)) { diff --git a/admin/partials/detections-page.php b/admin/partials/detections-page.php index f2136c4..564f508 100644 --- a/admin/partials/detections-page.php +++ b/admin/partials/detections-page.php @@ -18,18 +18,19 @@ global $wpdb; $table = $wpdb->prefix . 'webdecoy_detections'; +// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only admin list filtering via GET, no state change $page = isset($_GET['paged']) ? max(1, intval($_GET['paged'])) : 1; $per_page = 50; $offset = ($page - 1) * $per_page; // Filters -$threat_level = isset($_GET['threat_level']) ? sanitize_text_field($_GET['threat_level']) : ''; -$source = isset($_GET['source']) ? sanitize_text_field($_GET['source']) : ''; +$threat_level = isset($_GET['threat_level']) ? sanitize_text_field(wp_unslash($_GET['threat_level'])) : ''; +$source = isset($_GET['source']) ? sanitize_text_field(wp_unslash($_GET['source'])) : ''; // Date range handling -$date_from = isset($_GET['date_from']) ? sanitize_text_field($_GET['date_from']) : ''; -$date_to = isset($_GET['date_to']) ? sanitize_text_field($_GET['date_to']) : ''; -$range = isset($_GET['range']) ? sanitize_text_field($_GET['range']) : ''; +$date_from = isset($_GET['date_from']) ? sanitize_text_field(wp_unslash($_GET['date_from'])) : ''; +$date_to = isset($_GET['date_to']) ? sanitize_text_field(wp_unslash($_GET['date_to'])) : ''; +$range = isset($_GET['range']) ? sanitize_text_field(wp_unslash($_GET['range'])) : ''; if ($range === 'today') { $date_from = gmdate('Y-m-d'); @@ -68,7 +69,7 @@ $where = implode(' AND ', $where_clauses); // Handle CSV export -if (isset($_GET['action']) && $_GET['action'] === 'export_csv' && wp_verify_nonce($_GET['_wpnonce'] ?? '', 'webdecoy_export')) { +if (isset($_GET['action']) && sanitize_text_field(wp_unslash($_GET['action'])) === 'export_csv' && wp_verify_nonce(sanitize_text_field(wp_unslash($_GET['_wpnonce'] ?? '')), 'webdecoy_export')) { header('Content-Type: text/csv; charset=utf-8'); header('Content-Disposition: attachment; filename="webdecoy-detections-' . gmdate('Y-m-d') . '.csv"'); @@ -157,7 +158,7 @@ if (!empty($_GET['date_from']) || !empty($_GET['date_to'])) { $active_range = 'custom'; } elseif (isset($_GET['range'])) { - $active_range = sanitize_text_field($_GET['range']); + $active_range = sanitize_text_field(wp_unslash($_GET['range'])); } ?> get_results($wpdb->prepare( - "SELECT DATE(created_at) as date, COUNT(*) as count FROM {$detections_table} WHERE created_at > %s GROUP BY DATE(created_at) ORDER BY date ASC", + "SELECT DATE(created_at) as date, COUNT(*) as count FROM {$detections_table} WHERE created_at > %s GROUP BY DATE(created_at) ORDER BY date ASC", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, not user input $thirty_days_ago ), ARRAY_A); @@ -41,13 +41,13 @@ // Threat level distribution $threat_distribution = $wpdb->get_results($wpdb->prepare( - "SELECT threat_level, COUNT(*) as count FROM {$detections_table} WHERE created_at > %s GROUP BY threat_level ORDER BY count DESC", + "SELECT threat_level, COUNT(*) as count FROM {$detections_table} WHERE created_at > %s GROUP BY threat_level ORDER BY count DESC", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, not user input $thirty_days_ago ), ARRAY_A); // Top flagged signals (parse from JSON flags) $recent_flags = $wpdb->get_results($wpdb->prepare( - "SELECT flags FROM {$detections_table} WHERE created_at > %s AND flags IS NOT NULL AND flags != '' LIMIT 500", + "SELECT flags FROM {$detections_table} WHERE created_at > %s AND flags IS NOT NULL AND flags != '' LIMIT 500", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, not user input $thirty_days_ago ), ARRAY_A); @@ -78,29 +78,29 @@ // Top blocked IPs $top_ips = $wpdb->get_results($wpdb->prepare( - "SELECT ip_address, COUNT(*) as count, MAX(created_at) as last_seen FROM {$detections_table} WHERE created_at > %s GROUP BY ip_address ORDER BY count DESC LIMIT 10", + "SELECT ip_address, COUNT(*) as count, MAX(created_at) as last_seen FROM {$detections_table} WHERE created_at > %s GROUP BY ip_address ORDER BY count DESC LIMIT 10", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, not user input $thirty_days_ago ), ARRAY_A); // Source distribution $source_distribution = $wpdb->get_results($wpdb->prepare( - "SELECT source, COUNT(*) as count FROM {$detections_table} WHERE created_at > %s GROUP BY source ORDER BY count DESC", + "SELECT source, COUNT(*) as count FROM {$detections_table} WHERE created_at > %s GROUP BY source ORDER BY count DESC", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, not user input $thirty_days_ago ), ARRAY_A); // Overall stats $total_30d = array_sum($daily_data); $total_7d = $wpdb->get_var($wpdb->prepare( - "SELECT COUNT(*) FROM {$detections_table} WHERE created_at > %s", + "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 H:i:s', strtotime('-7 days')) )); $total_today = $wpdb->get_var($wpdb->prepare( - "SELECT COUNT(*) FROM {$detections_table} WHERE created_at > %s", + "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()" + "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 ); // WooCommerce stats (if active) @@ -108,11 +108,11 @@ if (class_exists('WooCommerce')) { $woo_stats = [ 'attempts' => (int) $wpdb->get_var($wpdb->prepare( - "SELECT COUNT(*) FROM {$checkout_table} WHERE created_at > %s", + "SELECT COUNT(*) FROM {$checkout_table} WHERE created_at > %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, not user input $thirty_days_ago )), 'blocked' => (int) $wpdb->get_var($wpdb->prepare( - "SELECT COUNT(*) FROM {$checkout_table} WHERE created_at > %s AND status = 'blocked'", + "SELECT COUNT(*) FROM {$checkout_table} WHERE created_at > %s AND status = 'blocked'", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, not user input $thirty_days_ago )), ]; diff --git a/cdn-files/plugin-info.json b/cdn-files/plugin-info.json index efc240f..02b69a5 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.2.1", + "version": "2.2.2", "author": "WebDecoy", "author_profile": "https://webdecoy.com", "requires": "5.6", "tested": "6.7", "requires_php": "7.4", - "download_url": "https://cdn.webdecoy.com/wordpress/webdecoy-2.2.1.zip", + "download_url": "https://cdn.webdecoy.com/wordpress/webdecoy-2.2.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

", "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/changelog.txt b/changelog.txt index c01ab90..5595a52 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,12 @@ *** WebDecoy Bot Detection Changelog *** += 2.2.2 - 2026-07-20 = +* Fixed: A PHP 7.4 fatal error in good-bot verification (str_ends_with is PHP 8.0+) +* Fixed: Timezone-safe date handling throughout (date() -> gmdate()) +* Changed: The bundled SDK now uses the WordPress HTTP API exclusively (removed the raw cURL fallback) +* Changed: Requires WordPress 6.1+ (wp_cache_flush_group); tested up to WordPress 7.0 +* Internal: Full WordPress Plugin Check compliance — resolved all 69 flagged errors + = 2.2.1 - 2026-07-19 = * Changed: Chart.js is now bundled with the plugin instead of loaded from a CDN — no external requests for the admin charts * Changed: Clarified the External Services disclosure in readme diff --git a/includes/class-webdecoy-detector.php b/includes/class-webdecoy-detector.php index 02dd1c0..7a2209d 100644 --- a/includes/class-webdecoy-detector.php +++ b/includes/class-webdecoy-detector.php @@ -167,9 +167,9 @@ private function is_login_page(): bool */ private function is_register_page(): bool { - return ($GLOBALS['pagenow'] ?? '') === 'wp-login.php' && - isset($_REQUEST['action']) && - $_REQUEST['action'] === 'register'; + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page detection, no state change + $action = isset($_REQUEST['action']) ? sanitize_text_field(wp_unslash($_REQUEST['action'])) : ''; + return ($GLOBALS['pagenow'] ?? '') === 'wp-login.php' && $action === 'register'; } /** diff --git a/includes/class-webdecoy-pow.php b/includes/class-webdecoy-pow.php index c99223e..0e315aa 100644 --- a/includes/class-webdecoy-pow.php +++ b/includes/class-webdecoy-pow.php @@ -146,7 +146,7 @@ public function get_difficulty_for_ip( $ip, $base = 4 ) { } // Check for known bot UA patterns - $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? $_SERVER['HTTP_USER_AGENT'] : ''; + $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; if ( $this->is_suspicious_ua( $ua ) ) { $difficulty++; } diff --git a/includes/class-webdecoy-violation-reporter.php b/includes/class-webdecoy-violation-reporter.php index 74968c5..7e5f1bf 100644 --- a/includes/class-webdecoy-violation-reporter.php +++ b/includes/class-webdecoy-violation-reporter.php @@ -140,7 +140,7 @@ public static function drain_queue(string $apiKey): void // Enforce the hard cap first: discard the oldest overflow so a prolonged // ingest outage can't grow the table without bound. - // phpcs:ignore WordPress.DB.DirectDatabaseQuery + // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, not user input $total = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table}"); if ($total > self::MAX_QUEUE) { $overflow = $total - self::MAX_QUEUE; diff --git a/includes/class-webdecoy-woocommerce.php b/includes/class-webdecoy-woocommerce.php index ac1d72b..e3b7f70 100644 --- a/includes/class-webdecoy-woocommerce.php +++ b/includes/class-webdecoy-woocommerce.php @@ -340,7 +340,7 @@ private function log_detection(string $ip, string $reason, ?int $score = null): // Log locally $wpdb->insert($table, [ 'ip_address' => $ip, - 'user_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field($_SERVER['HTTP_USER_AGENT']) : '', + 'user_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : '', 'score' => $final_score, 'threat_level' => 'HIGH', 'source' => $source, diff --git a/readme.txt b/readme.txt index 40157d4..5732ca2 100644 --- a/readme.txt +++ b/readme.txt @@ -4,12 +4,12 @@ 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.2.1 +Stable tag: 2.2.2 Requires PHP: 7.4 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html -Zero-configuration bot protection for WordPress. Works immediately on activation — no account, no API key, no external dependencies. Multi-layer detection with invisible proof-of-work challenges. +Zero-config bot, spam, and carding protection. Works instantly on activation, with no account, API key, or external connections. == Description == @@ -173,6 +173,9 @@ Privacy Policy: https://webdecoy.com/privacy **Without an API key, the plugin operates 100% locally — no external connections on the front end or back end.** Chart.js (used for the admin Statistics charts) is bundled with the plugin, not loaded from a CDN. += Bundled third-party libraries = +Chart.js v4.4.9 (MIT license) is included at admin/js/vendor/chart.umd.min.js for the admin Statistics charts. It is the official distribution build; the human-readable source is available at https://github.com/chartjs/Chart.js/releases/tag/v4.4.9 . No other third-party libraries are bundled. + == Screenshots == 1. Protection settings — configure detection and blocking @@ -186,6 +189,13 @@ Privacy Policy: https://webdecoy.com/privacy == Changelog == += 2.2.2 = +* Fixed: A PHP 7.4 fatal error in good-bot verification (use of a PHP 8 function) +* Fixed: Timezone-safe date handling throughout +* Changed: The bundled SDK now uses the WordPress HTTP API exclusively (removed the raw cURL fallback) +* Changed: Requires WordPress 6.1+; tested up to WordPress 7.0 +* Internal: Full WordPress Plugin Check compliance (resolved 69 flagged items) + = 2.2.1 = * Changed: Chart.js (admin Statistics charts) is now bundled with the plugin instead of loaded from a CDN — the plugin makes no external requests until you connect a WebDecoy Cloud account * Changed: Clarified the External Services disclosure diff --git a/sdk/src/BotDetector.php b/sdk/src/BotDetector.php index d38d889..424c4aa 100644 --- a/sdk/src/BotDetector.php +++ b/sdk/src/BotDetector.php @@ -265,7 +265,7 @@ private function getClientIP(): string foreach ($headers as $header) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- IP validated with FILTER_VALIDATE_IP below if (!empty($_SERVER[$header])) { - // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- WP path unslashes + sanitizes; the standalone fallback trims the raw value because wp_unslash() is unavailable outside WordPress $ip = function_exists('sanitize_text_field') ? sanitize_text_field(wp_unslash($_SERVER[$header])) : trim($_SERVER[$header]); // X-Forwarded-For can contain multiple IPs if (strpos($ip, ',') !== false) { diff --git a/sdk/src/SignalCollector.php b/sdk/src/SignalCollector.php index a87a5b4..7ad253e 100644 --- a/sdk/src/SignalCollector.php +++ b/sdk/src/SignalCollector.php @@ -82,7 +82,7 @@ private function getServerVar(string $key, ?string $default = null): ?string return $default; } - // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized below + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- WP path unslashes + sanitizes below; the standalone fallback trims the raw value because wp_unslash() is unavailable outside WordPress $value = $_SERVER[$key]; // Use WordPress sanitization if available diff --git a/webdecoy.php b/webdecoy.php index 7f692a0..504d85f 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.2.1 + * Version: 2.2.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.2.1'); +define('WEBDECOY_VERSION', '2.2.2'); define('WEBDECOY_PLUGIN_FILE', __FILE__); define('WEBDECOY_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('WEBDECOY_PLUGIN_URL', plugin_dir_url(__FILE__)); @@ -1109,13 +1109,13 @@ private function build_rule_context(string $ip): \WebDecoy\Rules\RuleContext ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : ''; - // Headers rules may read: Cookie (wd_clearance — passed raw so the token - // value survives intact for forwarding) and any header referenced via - // req.header("name"). We forward the full request header set for the - // latter, plus the raw cookie. + // Headers rules may read: Cookie (wd_clearance — the token's base64url/JWT + // characters pass through sanitize_text_field intact for forwarding) and + // any header referenced via req.header("name"). We forward the full request + // header set for the latter, plus the sanitized cookie header. $headers = $this->collect_request_headers(); if (isset($_SERVER['HTTP_COOKIE'])) { - $headers['cookie'] = (string) wp_unslash($_SERVER['HTTP_COOKIE']); + $headers['cookie'] = sanitize_text_field(wp_unslash($_SERVER['HTTP_COOKIE'])); } // Fetch IP enrichment only when a filter rule needs it (premium only). @@ -1342,7 +1342,7 @@ private function handle_blocking(\WebDecoy\DetectionResult $result, string $ip): */ private function is_challenge_verified(string $ip): bool { - $cookie = isset($_COOKIE['webdecoy_verified']) ? sanitize_text_field($_COOKIE['webdecoy_verified']) : ''; + $cookie = isset($_COOKIE['webdecoy_verified']) ? sanitize_text_field(wp_unslash($_COOKIE['webdecoy_verified'])) : ''; if (empty($cookie)) { return false; } @@ -1418,7 +1418,7 @@ private function log_detection(\WebDecoy\DetectionResult $result, string $ip): v $wpdb->insert($table, [ 'ip_address' => $ip, - 'user_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field($_SERVER['HTTP_USER_AGENT']) : '', + 'user_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : '', 'score' => $result->getScore(), 'threat_level' => $result->getThreatLevel(), 'source' => 'wordpress_plugin', @@ -1648,6 +1648,7 @@ private function check_honeypot(string $context): void { $honeypot_name = 'webdecoy_hp_' . $context; + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- honeypot detection: any submitted value is treated as a bot signal, so nonce verification is not applicable if (isset($_POST[$honeypot_name]) && !empty($_POST[$honeypot_name])) { // Honeypot triggered - definitely a bot $ip = $this->get_client_ip(); @@ -2122,7 +2123,7 @@ public function ajax_client_detection(): void } // Get detection data - $detection_json = isset($_POST['detection']) ? wp_unslash($_POST['detection']) : ''; + $detection_json = isset($_POST['detection']) ? sanitize_text_field(wp_unslash($_POST['detection'])) : ''; $detection = json_decode($detection_json, true); if (!$detection || !is_array($detection)) { @@ -2294,7 +2295,7 @@ public function ajax_test_connection(): void return; } - $api_key = sanitize_text_field($_POST['api_key'] ?? ''); + $api_key = sanitize_text_field(wp_unslash($_POST['api_key'] ?? '')); // Decrypt if encrypted if (!empty($api_key) && $this->is_encrypted($api_key)) { @@ -2369,8 +2370,8 @@ public function ajax_block_ip(): void return; } - $ip = sanitize_text_field($_POST['ip'] ?? ''); - $reason = sanitize_text_field($_POST['reason'] ?? ''); + $ip = sanitize_text_field(wp_unslash($_POST['ip'] ?? '')); + $reason = sanitize_text_field(wp_unslash($_POST['reason'] ?? '')); $duration = intval($_POST['duration'] ?? 24); if (empty($ip) || !filter_var($ip, FILTER_VALIDATE_IP)) { @@ -2396,7 +2397,7 @@ public function ajax_unblock_ip(): void return; } - $ip = sanitize_text_field($_POST['ip'] ?? ''); + $ip = sanitize_text_field(wp_unslash($_POST['ip'] ?? '')); if (empty($ip)) { wp_send_json_error(['message' => __('Invalid IP address.', 'webdecoy')]); @@ -2421,7 +2422,7 @@ public function ajax_bulk_block(): void return; } - $ips = isset($_POST['ips']) ? array_map('sanitize_text_field', (array) $_POST['ips']) : []; + $ips = isset($_POST['ips']) ? array_map('sanitize_text_field', wp_unslash((array) $_POST['ips'])) : []; if (empty($ips)) { wp_send_json_error(['message' => __('No IPs selected.', 'webdecoy')]); @@ -2484,7 +2485,7 @@ public function ajax_pow_verify(): void $ip = $this->get_client_ip(); // Get challenge data - $challenge_json = isset($_POST['challenge']) ? wp_unslash($_POST['challenge']) : ''; + $challenge_json = isset($_POST['challenge']) ? sanitize_text_field(wp_unslash($_POST['challenge'])) : ''; $challenge = json_decode($challenge_json, true); if (!$challenge || !is_array($challenge)) { @@ -2493,7 +2494,7 @@ public function ajax_pow_verify(): void } $nonce = isset($_POST['pow_nonce']) ? intval($_POST['pow_nonce']) : 0; - $hash = isset($_POST['pow_hash']) ? wp_unslash($_POST['pow_hash']) : ''; + $hash = isset($_POST['pow_hash']) ? sanitize_text_field(wp_unslash($_POST['pow_hash'])) : ''; if (!preg_match('/^[0-9a-f]{64}$/i', $hash)) { wp_send_json_error(['message' => __('Invalid hash.', 'webdecoy')]); return; @@ -2509,7 +2510,7 @@ public function ajax_pow_verify(): void } // Score behavioral signals if provided - $behavioral_json = isset($_POST['behavioral']) ? wp_unslash($_POST['behavioral']) : ''; + $behavioral_json = isset($_POST['behavioral']) ? sanitize_text_field(wp_unslash($_POST['behavioral'])) : ''; $behavioral = json_decode($behavioral_json, true); if ($behavioral && is_array($behavioral)) {