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
8 changes: 5 additions & 3 deletions admin/partials/blocked-ips-page.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<div class="notice notice-success"><p>' . esc_html__('IP blocked successfully.', 'webdecoy') . '</p></div>';
} else {
echo '<div class="notice notice-error"><p>' . esc_html__('Invalid IP address.', 'webdecoy') . '</p></div>';
Expand Down Expand Up @@ -126,10 +127,11 @@
<tr>
<td><code><?php echo esc_html($block['ip_address']); ?></code></td>
<td><?php echo esc_html($block['reason'] ?: '-'); ?></td>
<td><?php echo esc_html(date_i18n(get_option('date_format') . ' ' . get_option('time_format'), strtotime($block['blocked_at']))); ?></td>
<?php // blocked_at/expires_at are stored UTC; convert before formatting or the site shows UTC labelled as local. ?>
<td><?php echo esc_html(date_i18n(get_option('date_format') . ' ' . get_option('time_format'), strtotime(get_date_from_gmt($block['blocked_at'])))); ?></td>
<td>
<?php if ($block['expires_at']) : ?>
<?php echo esc_html(date_i18n(get_option('date_format') . ' ' . get_option('time_format'), strtotime($block['expires_at']))); ?>
<?php echo esc_html(date_i18n(get_option('date_format') . ' ' . get_option('time_format'), strtotime(get_date_from_gmt($block['expires_at'])))); ?>
<?php else : ?>
<em><?php esc_html_e('Never (permanent)', 'webdecoy'); ?></em>
<?php endif; ?>
Expand Down
26 changes: 25 additions & 1 deletion admin/partials/settings-page.php
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,30 @@
<h2><?php esc_html_e('Blocking Settings', 'webdecoy'); ?></h2>

<table class="form-table">
<tr>
<th scope="row">
<label for="webdecoy_monitor_mode"><?php esc_html_e('Monitor mode', 'webdecoy'); ?></label>
</th>
<td>
<label>
<input type="checkbox" id="webdecoy_monitor_mode" name="webdecoy_options[monitor_mode]"
value="1" <?php checked(!array_key_exists('monitor_mode', $options) || !empty($options['monitor_mode'])); ?> />
<?php esc_html_e('Watch only — detect and log everything, block nothing', 'webdecoy'); ?>
</label>
<p class="description">
<?php esc_html_e('On by default. Every setting below still decides what WOULD happen, and the Detections page shows it, but no visitor is ever blocked, throttled or shown a 403. Turn this off once you have looked at what enforcement would have done.', 'webdecoy'); ?>
</p>
<p class="description">
<?php
printf(
/* translators: %s: the PHP constant, already formatted as code */
esc_html__('Emergency off switch: add %s to wp-config.php to stop the plugin acting on the front end entirely, without database access.', 'webdecoy'),
'<code>define(\'WEBDECOY_DISABLE\', true);</code>'
);
?>
</p>
</td>
</tr>
<tr>
<th scope="row">
<label for="webdecoy_ip_allowlist"><?php esc_html_e('IP Allowlist', 'webdecoy'); ?></label>
Expand Down Expand Up @@ -523,7 +547,7 @@
</th>
<td>
<input type="number" id="webdecoy_block_duration" name="webdecoy_options[block_duration]"
value="<?php echo esc_attr($options['block_duration'] ?? 24); ?>"
value="<?php echo esc_attr($options['block_duration'] ?? 1); ?>"
min="0" step="1" class="small-text" />
<?php esc_html_e('hours (0 = permanent)', 'webdecoy'); ?>
</td>
Expand Down
11 changes: 7 additions & 4 deletions admin/partials/statistics-page.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
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.3.1",
"version": "2.3.2",
"author": "<a href=\"https://webdecoy.com\">WebDecoy</a>",
"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": "<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, rotation-proof device lockouts, and WAF integrations (push confirmed attackers to Cloudflare or AWS WAF — blocked before they reach WordPress)</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
15 changes: 15 additions & 0 deletions changelog.txt
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
6 changes: 5 additions & 1 deletion includes/class-webdecoy-activator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.',

Expand Down
127 changes: 74 additions & 53 deletions includes/class-webdecoy-actor-feed.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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<string,array{actor_id:string,last_seen:int}> $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;
}

/**
Expand Down
Loading
Loading