Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions admin/partials/blocked-ips-page.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
15 changes: 8 additions & 7 deletions admin/partials/detections-page.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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"');

Expand Down Expand Up @@ -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']));
}
?>
<a href="<?php echo esc_url(add_query_arg(['page' => 'webdecoy-detections', 'range' => 'today'], admin_url('admin.php'))); ?>"
Expand Down
20 changes: 10 additions & 10 deletions admin/partials/statistics-page.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@

// 30-day detection trend
$thirty_days_ago = gmdate('Y-m-d H:i:s', strtotime('-30 days'));
$daily_counts = $wpdb->get_results($wpdb->prepare(

Check warning on line 25 in admin/partials/statistics-page.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

Direct database call without caching detected. Consider using wp_cache_get() / wp_cache_set() or wp_cache_delete().

Check warning on line 25 in admin/partials/statistics-page.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

Use of a direct database call is discouraged.
"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);

Expand All @@ -40,14 +40,14 @@
}

// Threat level distribution
$threat_distribution = $wpdb->get_results($wpdb->prepare(

Check warning on line 43 in admin/partials/statistics-page.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

Direct database call without caching detected. Consider using wp_cache_get() / wp_cache_set() or wp_cache_delete().

Check warning on line 43 in admin/partials/statistics-page.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

Use of a direct database call is discouraged.
"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(

Check warning on line 49 in admin/partials/statistics-page.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

Use of a direct database call is discouraged.
"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);

Expand Down Expand Up @@ -78,41 +78,41 @@

// 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)
$woo_stats = null;
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
)),
];
Expand Down
4 changes: 2 additions & 2 deletions cdn-files/plugin-info.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
{
"name": "WebDecoy Bot Detection",
"slug": "webdecoy",
"version": "2.2.1",
"version": "2.2.2",
"author": "<a href=\"https://webdecoy.com\">WebDecoy</a>",
"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": "<p>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.</p><h4>Key Features</h4><ul><li>Deterministic tripwires (hidden honeypot paths) — zero-false-positive bot blocking</li><li>Server-side and client-side bot detection</li><li>Invisible proof-of-work challenge (no external CAPTCHA service)</li><li>Comment, login, and registration spam protection</li><li>WooCommerce carding attack prevention</li><li>60+ good bots automatically allowed</li><li>AI crawler detection and blocking</li><li>Optional WebDecoy Cloud: centralized dashboard and rotation-proof device lockouts</li></ul>",
"installation": "<ol><li>Upload the plugin files to <code>/wp-content/plugins/webdecoy</code></li><li>Activate the plugin through the Plugins menu</li><li>Tripwires and local protection are active out of the box — no API key required</li><li>Optionally go to WebDecoy &gt; Settings &gt; WebDecoy Cloud to connect for centralized monitoring and enforcement</li></ol>",
Expand Down
7 changes: 7 additions & 0 deletions changelog.txt
Original file line number Diff line number Diff line change
@@ -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
Expand Down
6 changes: 3 additions & 3 deletions includes/class-webdecoy-detector.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}

/**
Expand Down
2 changes: 1 addition & 1 deletion includes/class-webdecoy-pow.php
Original file line number Diff line number Diff line change
Expand Up @@ -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++;
}
Expand Down
2 changes: 1 addition & 1 deletion includes/class-webdecoy-violation-reporter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion includes/class-webdecoy-woocommerce.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 12 additions & 2 deletions readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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 ==

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion sdk/src/BotDetector.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion sdk/src/SignalCollector.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading