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
26 changes: 26 additions & 0 deletions admin/partials/settings-page.php
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,32 @@
min="1" max="3600" class="small-text" />
<?php esc_html_e('seconds', 'webdecoy'); ?>
</label>
<br><br>
<label>
<?php esc_html_e('Algorithm', 'webdecoy'); ?>
<select name="webdecoy_options[rate_limit_algorithm]">
<option value="fixed" <?php selected($options['rate_limit_algorithm'] ?? 'fixed', 'fixed'); ?>><?php esc_html_e('Fixed window', 'webdecoy'); ?></option>
<option value="sliding" <?php selected($options['rate_limit_algorithm'] ?? 'fixed', 'sliding'); ?>><?php esc_html_e('Sliding window', 'webdecoy'); ?></option>
</select>
</label>
&nbsp;
<label>
<?php esc_html_e('Count by', 'webdecoy'); ?>
<select name="webdecoy_options[rate_limit_key]">
<option value="ip" <?php selected($options['rate_limit_key'] ?? 'ip', 'ip'); ?>><?php esc_html_e('IP address', 'webdecoy'); ?></option>
<option value="ip_route" <?php selected($options['rate_limit_key'] ?? 'ip', 'ip_route'); ?>><?php esc_html_e('IP + route', 'webdecoy'); ?></option>
<option value="user" <?php selected($options['rate_limit_key'] ?? 'ip', 'user'); ?>><?php esc_html_e('Logged-in user', 'webdecoy'); ?></option>
</select>
</label>
<br><br>
<label>
<input type="checkbox" name="webdecoy_options[rate_limit_dry_run]" value="1"
<?php checked(!empty($options['rate_limit_dry_run'])); ?> />
<?php esc_html_e('Dry run (record without throttling)', 'webdecoy'); ?>
</label>
<p class="description">
<?php esc_html_e('Over-limit requests get a 429 with Retry-After and X-RateLimit-* headers. Sliding window uses a persistent object cache (Redis/Memcached) when available, otherwise falls back to the fixed-window database counter.', 'webdecoy'); ?>
</p>
</td>
</tr>
</table>
Expand Down
1 change: 1 addition & 0 deletions changelog.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
= 2.2.0 - Unreleased =
* 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.
* Added: Stealth-browser detection (F1) — catches scrapers that patch native browser functions to hide automation (puppeteer-extra-stealth, botasaurus, etc.), the class of tool that defeats conventional fingerprinting. Strong tells (patched Function.prototype.toString, a modified navigator.webdriver getter) are decisive; weak tells that real privacy extensions can trigger are scored gently so they never block a legitimate visitor.
* Added: Clearance client — bundled @webdecoy/client browser script that silently mints a wd_clearance cookie for real visitors (idle-deferred, once per session, no proof-of-work). Enables tripwire/decoy hits to durably lock out the offending device. Configured via a new publishable Site Key in the WebDecoy Cloud tab.
* Added: Rule engine — deterministic rules evaluated before heuristic scoring; first DENY/THROTTLE wins, with dry-run (log without blocking). Parity with @webdecoy/node.
Expand Down
181 changes: 181 additions & 0 deletions includes/class-webdecoy-rate-limit-rule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
<?php

declare(strict_types=1);

use WebDecoy\Rules\RuleContext;
use WebDecoy\Rules\RuleResult;
use WebDecoy\Rules\RuleInterface;

if (!defined('ABSPATH')) {
exit;
}

/**
* Rate-limit rule — a {@see RuleInterface} so rate limiting flows through the
* same engine as tripwires and filters, and produces a proper THROTTLE (429 +
* Retry-After + X-RateLimit-* headers) instead of merely nudging the bot score.
*
* Faithful to @webdecoy/node's RateLimitRule (increment-then-check, THROTTLE
* default, retryAfter metadata), adapted to PHP's stateless model:
*
* - fixed window → DB (webdecoy_rate_limits), survives across processes.
* - sliding window → the classic two-bucket weighted approximation in a
* persistent object cache (Redis/Memcached). Most serious WP hosts run one,
* giving shared cross-process state node's in-memory limiter can't keep
* across restarts. When no persistent object cache is present, sliding
* transparently falls back to the fixed-window DB path.
*
* keyBy: 'ip' (default), 'ip_route' (IP + path), or 'user' (logged-in user id,
* falling back to IP for anonymous requests).
*/
class WebDecoy_Rate_Limit_Rule implements RuleInterface
{
/** Object-cache group for sliding-window buckets. */
private const CACHE_GROUP = 'webdecoy_rl';

/** @var int */
private $limit;

/** @var int */
private $window;

/** @var string 'fixed' | 'sliding' */
private $algorithm;

/** @var string 'ip' | 'ip_route' | 'user' */
private $keyBy;

/** @var string DENY | THROTTLE */
private $action;

/** @var bool */
private $dryRun;

/**
* @param array<string,mixed> $config
*/
public function __construct(array $config)
{
$this->limit = max(1, (int) ($config['limit'] ?? 60));
$this->window = max(1, (int) ($config['window'] ?? 60));

$algorithm = $config['algorithm'] ?? 'fixed';
$this->algorithm = in_array($algorithm, ['fixed', 'sliding'], true) ? $algorithm : 'fixed';

$keyBy = $config['keyBy'] ?? 'ip';
$this->keyBy = in_array($keyBy, ['ip', 'ip_route', 'user'], true) ? $keyBy : 'ip';

$this->action = ($config['action'] ?? RuleResult::THROTTLE) === RuleResult::DENY ? RuleResult::DENY : RuleResult::THROTTLE;
$this->dryRun = !empty($config['dryRun']);
}

public function getName(): string
{
return 'rate-limit:' . $this->limit . '/' . $this->window . 's';
}

public function evaluate(RuleContext $context): RuleResult
{
$key = $this->resolveKey($context);

$useSliding = $this->algorithm === 'sliding'
&& function_exists('wp_using_ext_object_cache')
&& wp_using_ext_object_cache();

$res = $useSliding ? $this->checkSliding($key) : $this->checkFixed($key);

$remaining = max(0, $this->limit - (int) $res['current']);
$resetIn = max(1, (int) $res['resetAt'] - time());

if (!$res['allowed']) {
return new RuleResult(
$this->dryRun ? RuleResult::ALLOW : $this->action,
$this->getName(),
'Rate limit exceeded: ' . $res['current'] . '/' . $this->limit . ' requests in ' . $this->window . 's window',
[
'current' => (int) $res['current'],
'max' => $this->limit,
'window' => $this->window,
'remaining' => 0,
'retryAfter' => $resetIn,
'resetAt' => (int) $res['resetAt'],
'dryRun' => $this->dryRun,
]
);
}

// Allowed: still carry the counters so the caller can emit X-RateLimit-*.
return new RuleResult(RuleResult::ALLOW, $this->getName(), null, [
'current' => (int) $res['current'],
'max' => $this->limit,
'remaining' => $remaining,
'resetAt' => (int) $res['resetAt'],
]);
}

/**
* Build the counting key. Composite keys are hashed to a fixed length so
* they fit the DB column; a plain IP is kept readable for stats/debugging.
*/
private function resolveKey(RuleContext $context): string
{
if ($this->keyBy === 'user') {
$uid = function_exists('get_current_user_id') ? (int) get_current_user_id() : 0;
if ($uid > 0) {
return 'u' . $uid;
}
return $context->ip; // anonymous → fall back to IP
}

if ($this->keyBy === 'ip_route') {
$path = explode('?', $context->path, 2)[0];
return substr(sha1($context->ip . '|' . $path), 0, 40);
}

return $context->ip;
}

/**
* Fixed-window check-and-increment via the DB. Increment first, then compare
* (node parity: count > max after increment → denied).
*
* @return array{allowed:bool,current:int,resetAt:int}
*/
private function checkFixed(string $key): array
{
$limiter = new WebDecoy_Rate_Limiter($this->limit, $this->window);
return $limiter->check_and_increment($key);
}

/**
* Sliding-window via a two-bucket weighted approximation in the object cache.
* estimate = prev_bucket_count * overlap_fraction + current_bucket_count.
*
* @return array{allowed:bool,current:int,resetAt:int}
*/
private function checkSliding(string $key): array
{
$now = time();
$win = $this->window;
$idx = intdiv($now, $win);
$currKey = $key . ':' . $idx;
$prevKey = $key . ':' . ($idx - 1);

$curr = wp_cache_incr($currKey, 1, self::CACHE_GROUP);
if ($curr === false) {
wp_cache_add($currKey, 1, self::CACHE_GROUP, $win * 2);
$curr = 1;
}

$prev = (int) wp_cache_get($prevKey, self::CACHE_GROUP);
$elapsed = $now % $win;
$weight = ($win - $elapsed) / $win;
$estimate = ($prev * $weight) + $curr;

return [
'allowed' => $estimate <= $this->limit,
'current' => (int) ceil($estimate),
'resetAt' => ($idx + 1) * $win,
];
}
}
44 changes: 44 additions & 0 deletions includes/class-webdecoy-rate-limiter.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,50 @@ public function is_exceeded(string $ip): bool
return $count >= $this->limit;
}

/**
* Fixed-window check-and-increment for an arbitrary key. Increments first,
* then reports whether the new count is within the limit (matches
* @webdecoy/node's RateLimitRule semantics). Used by the rate-limit rule.
*
* @param string $key IP or composite/hashed key
* @return array{allowed:bool,current:int,resetAt:int}
*/
public function check_and_increment(string $key): array
{
global $wpdb;

$table = $wpdb->prefix . 'webdecoy_rate_limits';
$now = current_time('mysql');
$window_start_threshold = date('Y-m-d H:i:s', strtotime("-{$this->window} seconds"));

$existing = $wpdb->get_row($wpdb->prepare(
"SELECT request_count, window_start FROM {$table} WHERE ip_address = %s AND window_start > %s",
$key,
$window_start_threshold
), ARRAY_A);

if ($existing) {
$current = (int) $existing['request_count'] + 1;
$wpdb->update($table, ['request_count' => $current], ['ip_address' => $key]);
$reset_at = strtotime($existing['window_start']) + $this->window;
} else {
$wpdb->delete($table, ['ip_address' => $key]);
$wpdb->insert($table, [
'ip_address' => $key,
'request_count' => 1,
'window_start' => $now,
]);
$current = 1;
$reset_at = strtotime($now) + $this->window;
}

return [
'allowed' => $current <= $this->limit,
'current' => $current,
'resetAt' => $reset_at,
];
}

/**
* Increment request count for an IP
*
Expand Down
69 changes: 34 additions & 35 deletions webdecoy.php
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,12 @@
'rate_limit_enabled' => true,
'rate_limit_requests' => 60,
'rate_limit_window' => 60,
// Algorithm: fixed window (DB) or sliding window (object cache when a
// persistent one is present, else fixed). Key: per IP, IP+route, or
// logged-in user. Dry-run records without throttling.
'rate_limit_algorithm' => 'fixed',
'rate_limit_key' => 'ip',
'rate_limit_dry_run' => false,

// Tripwires (F4 deception layer). Deterministic, zero-false-positive:
// a request for a scanner-bait honeypot path is automated by
Expand Down Expand Up @@ -751,6 +757,7 @@
require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-honeytoken.php';
require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-ip-enrichment.php';
require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-decoy-response.php';
require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-rate-limit-rule.php';

if (class_exists('WooCommerce')) {
require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-woocommerce.php';
Expand Down Expand Up @@ -837,15 +844,9 @@
}
}

// Check rate limit
if ($this->options['rate_limit_enabled']) {
$rateLimiter = new WebDecoy_Rate_Limiter();
if ($rateLimiter->is_exceeded($ip)) {
$this->handle_rate_limit_exceeded($ip);
return;
}
$rateLimiter->increment($ip);
}
// Rate limiting now runs as a rule inside the engine above (THROTTLE →
// 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();
Expand Down Expand Up @@ -949,6 +950,18 @@
}
}

// Rate limiting runs last: a deterministic tripwire/filter DENY should
// win over a THROTTLE for the same request.
if (!empty($this->options['rate_limit_enabled'])) {
$rules[] = new WebDecoy_Rate_Limit_Rule([
'limit' => (int) ($this->options['rate_limit_requests'] ?? 60),
'window' => (int) ($this->options['rate_limit_window'] ?? 60),
'algorithm' => $this->options['rate_limit_algorithm'] ?? 'fixed',
'keyBy' => $this->options['rate_limit_key'] ?? 'ip',
'dryRun' => !empty($this->options['rate_limit_dry_run']),
]);
}

if ($rules === []) {
return null;
}
Expand Down Expand Up @@ -1012,7 +1025,7 @@
// latter, plus the raw cookie.
$headers = $this->collect_request_headers();
if (isset($_SERVER['HTTP_COOKIE'])) {
$headers['cookie'] = (string) wp_unslash($_SERVER['HTTP_COOKIE']);

Check failure on line 1028 in webdecoy.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

Detected usage of a non-sanitized input variable: $_SERVER['HTTP_COOKIE']
}

// Fetch IP enrichment only when a filter rule needs it (premium only).
Expand Down Expand Up @@ -1062,13 +1075,18 @@
private function handle_rule_decision(\WebDecoy\Rules\RuleEngineResult $result, string $ip): void
{
if ($result->action === \WebDecoy\Rules\RuleResult::THROTTLE) {
$retryAfter = 60;
if (is_array($result->metadata) && isset($result->metadata['retryAfter'])) {
$retryAfter = max(1, intval($result->metadata['retryAfter']));
}
$meta = is_array($result->metadata) ? $result->metadata : [];
$retryAfter = isset($meta['retryAfter']) ? max(1, intval($meta['retryAfter'])) : 60;
nocache_headers();
status_header(429);
header('Retry-After: ' . $retryAfter);
if (isset($meta['max'])) {
header('X-RateLimit-Limit: ' . (int) $meta['max']);
header('X-RateLimit-Remaining: ' . (int) ($meta['remaining'] ?? 0));
if (isset($meta['resetAt'])) {
header('X-RateLimit-Reset: ' . (int) $meta['resetAt']);
}
}
wp_die(
esc_html__('Too many requests. Please try again later.', 'webdecoy'),
esc_html__('Too Many Requests', 'webdecoy'),
Expand Down Expand Up @@ -1129,7 +1147,7 @@
],
];

$wpdb->insert($wpdb->prefix . 'webdecoy_detections', [

Check warning on line 1150 in webdecoy.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

Use of a direct database call is discouraged.
'ip_address' => $ip,
'user_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : '',
'score' => 100,
Expand Down Expand Up @@ -1170,7 +1188,7 @@
),
];

$wpdb->insert($wpdb->prefix . 'webdecoy_detections', [

Check warning on line 1191 in webdecoy.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

Use of a direct database call is discouraged.
'ip_address' => $violation->ip,
'user_agent' => $violation->userAgent ?? '',
'score' => $confidence,
Expand Down Expand Up @@ -1234,7 +1252,7 @@
*/
private function is_challenge_verified(string $ip): bool
{
$cookie = isset($_COOKIE['webdecoy_verified']) ? sanitize_text_field($_COOKIE['webdecoy_verified']) : '';

Check failure on line 1255 in webdecoy.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

$_COOKIE['webdecoy_verified'] not unslashed before sanitization. Use wp_unslash() or similar
if (empty($cookie)) {
return false;
}
Expand Down Expand Up @@ -1267,28 +1285,6 @@
exit;
}

/**
* Handle rate limit exceeded
*
* @param string $ip
*/
private function handle_rate_limit_exceeded(string $ip): void
{
// Add rate exceeded flag to detection
$detector = $this->get_detector();
$result = $detector->analyze(['rate_exceeded' => true]);

// Log rate limit exceeded
$this->log_detection($result, $ip);

if ($result->getScore() >= $this->options['min_score_to_block']) {
$this->handle_blocking($result, $ip);
} else {
// Just block temporarily for rate limiting
$this->block_request(__('Too many requests. Please try again later.', 'webdecoy'));
}
}

/**
* Block a request
*
Expand Down Expand Up @@ -1330,9 +1326,9 @@
'metadata' => $result->getMetadata(),
];

$wpdb->insert($table, [

Check warning on line 1329 in webdecoy.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

Use of a direct database call is discouraged.
'ip_address' => $ip,
'user_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field($_SERVER['HTTP_USER_AGENT']) : '',

Check failure on line 1331 in webdecoy.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

$_SERVER['HTTP_USER_AGENT'] not unslashed before sanitization. Use wp_unslash() or similar
'score' => $result->getScore(),
'threat_level' => $result->getThreatLevel(),
'source' => 'wordpress_plugin',
Expand Down Expand Up @@ -1432,7 +1428,7 @@
$ip = $this->get_client_ip();

global $wpdb;
$wpdb->insert($wpdb->prefix . 'webdecoy_detections', [

Check warning on line 1431 in webdecoy.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

Use of a direct database call is discouraged.
'ip_address' => $ip,
'user_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : '',
'score' => 100,
Expand Down Expand Up @@ -1562,7 +1558,7 @@
{
$honeypot_name = 'webdecoy_hp_' . $context;

if (isset($_POST[$honeypot_name]) && !empty($_POST[$honeypot_name])) {

Check failure on line 1561 in webdecoy.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

Processing form data without nonce verification.

Check failure on line 1561 in webdecoy.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

Processing form data without nonce verification.
// Honeypot triggered - definitely a bot
$ip = $this->get_client_ip();
$blocker = new WebDecoy_Blocker();
Expand Down Expand Up @@ -1719,6 +1715,9 @@
$sanitized['rate_limit_enabled'] = !empty($input['rate_limit_enabled']);
$sanitized['rate_limit_requests'] = max(1, intval($input['rate_limit_requests'] ?? 60));
$sanitized['rate_limit_window'] = max(1, intval($input['rate_limit_window'] ?? 60));
$sanitized['rate_limit_algorithm'] = in_array($input['rate_limit_algorithm'] ?? 'fixed', ['fixed', 'sliding'], true) ? $input['rate_limit_algorithm'] : 'fixed';
$sanitized['rate_limit_key'] = in_array($input['rate_limit_key'] ?? 'ip', ['ip', 'ip_route', 'user'], true) ? $input['rate_limit_key'] : 'ip';
$sanitized['rate_limit_dry_run'] = !empty($input['rate_limit_dry_run']);

// Tripwires
$sanitized['tripwire_enabled'] = !empty($input['tripwire_enabled']);
Expand Down Expand Up @@ -2015,7 +2014,7 @@
}

// Get detection data
$detection_json = isset($_POST['detection']) ? wp_unslash($_POST['detection']) : '';

Check failure on line 2017 in webdecoy.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

Detected usage of a non-sanitized input variable: $_POST['detection']
$detection = json_decode($detection_json, true);

if (!$detection || !is_array($detection)) {
Expand Down Expand Up @@ -2110,7 +2109,7 @@
$threat_level = 'LOW';
}

$wpdb->insert($table, [

Check warning on line 2112 in webdecoy.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

Use of a direct database call is discouraged.
'ip_address' => $ip,
'user_agent' => $user_agent,
'score' => $score,
Expand Down Expand Up @@ -2187,7 +2186,7 @@
return;
}

$api_key = sanitize_text_field($_POST['api_key'] ?? '');

Check failure on line 2189 in webdecoy.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

$_POST['api_key'] not unslashed before sanitization. Use wp_unslash() or similar

// Decrypt if encrypted
if (!empty($api_key) && $this->is_encrypted($api_key)) {
Expand Down Expand Up @@ -2262,8 +2261,8 @@
return;
}

$ip = sanitize_text_field($_POST['ip'] ?? '');

Check failure on line 2264 in webdecoy.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

$_POST['ip'] not unslashed before sanitization. Use wp_unslash() or similar
$reason = sanitize_text_field($_POST['reason'] ?? '');

Check failure on line 2265 in webdecoy.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

$_POST['reason'] not unslashed before sanitization. Use wp_unslash() or similar
$duration = intval($_POST['duration'] ?? 24);

if (empty($ip) || !filter_var($ip, FILTER_VALIDATE_IP)) {
Expand All @@ -2289,7 +2288,7 @@
return;
}

$ip = sanitize_text_field($_POST['ip'] ?? '');

Check failure on line 2291 in webdecoy.php

View workflow job for this annotation

GitHub Actions / PHP Coding Standards

$_POST['ip'] not unslashed before sanitization. Use wp_unslash() or similar

if (empty($ip)) {
wp_send_json_error(['message' => __('Invalid IP address.', 'webdecoy')]);
Expand Down Expand Up @@ -2689,7 +2688,7 @@
return $transient;
}

$transient->response[WEBDECOY_PLUGIN_BASENAME] = (object) [

Check failure on line 2691 in webdecoy.php

View workflow job for this annotation

GitHub Actions / Static Analysis

Access to an undefined property object::$response.
'slug' => 'webdecoy',
'plugin' => WEBDECOY_PLUGIN_BASENAME,
'new_version' => $update_info['version'],
Expand Down
Loading