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
3 changes: 3 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
"email": "plugins@cleantalk.org"
}
],
"require": {
"cleantalk/rate-limiter": "*"
},
"require-dev": {
"vimeo/psalm": "^4.8",
"phpunit/phpunit": "^8.5.52",
Expand Down
178 changes: 178 additions & 0 deletions lib/Cleantalk/ApbctWP/RateLimit/ApbctRateLimiter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
<?php

namespace Cleantalk\ApbctWP\RateLimit;

use Cleantalk\Common\RateLimiter\RateLimiter;
use Cleantalk\Common\RateLimiter\RateLimiterDto;
use Cleantalk\ApbctWP\Helper;
use Cleantalk\ApbctWP\Variables\Server;

/**
* WordPress-specific implementation of the rate limiter for Anti-Spam plugin
*
* @package Cleantalk\ApbctWP\RateLimit
*/
class ApbctRateLimiter extends RateLimiter
{
/**
* Sets the IP address from WordPress environment
*
* @return void
*/
protected function setIP(): void
{
$this->ip = (string) Helper::ipGet('remote_addr', true);
}

/**
* Sets the User-Agent from server variables
*
* @return void
*/
protected function setUA(): void
{
$this->ua = Server::getString('HTTP_USER_AGENT');
}

/**
* Override UID to be per-IP only (ignoring UA),
* so an attacker cannot bypass the limit by rotating User-Agent.
*
* @return void
*/
protected function setUID(): void
{
$this->uid = md5($this->ip . $this->config->type);
}

/**
* Performs health check to ensure database table exists and config is valid
*
* @return bool
*/
protected function healthCheck(): bool
{
global $wpdb;

if ( ! defined('APBCT_TBL_RATE_LIMITS') ) {
return false;
}

$table_name = APBCT_TBL_RATE_LIMITS;
$sql = $wpdb->prepare('SHOW TABLES LIKE %s', $table_name);
$table_ok = ! empty($wpdb->get_var($sql));

return $table_ok && parent::healthCheck();
}

/**
* Handles rate limiter errors (silent fail - allow request through)
*
* @param string $msg Error message
* @return void
*/
protected function handleErrors(string $msg): void
{
// Silent fail - errors are handled by process_ok flag in the caller
}

/**
* Retrieves rate limit data for the current UID from database
*
* @return RateLimiterDto|false
*/
protected function selectUIDData()
{
global $wpdb;

$table_name = APBCT_TBL_RATE_LIMITS;

$sql = $wpdb->prepare(
"SELECT uid, type, ip, ua, counter, last_call, created_at FROM {$table_name} WHERE uid = %s LIMIT 1",
$this->uid
);

$result = $wpdb->get_row($sql, ARRAY_A);

return ! empty($result) ? new RateLimiterDto($result) : false;
}

/**
* Inserts a new rate limit record
*
* @param RateLimiterDto $uid_data
* @return bool
*/
protected function insert(RateLimiterDto $uid_data): bool
{
global $wpdb;

$table_name = APBCT_TBL_RATE_LIMITS;

$sql = $wpdb->prepare(
"INSERT INTO {$table_name} (uid, type, ip, ua, counter, last_call, created_at)
VALUES (%s, %s, %s, %s, 1, %d, %d)
ON DUPLICATE KEY UPDATE last_call = %d, counter = counter + 1",
$uid_data->uid,
$uid_data->type,
$uid_data->ip,
$uid_data->ua,
$uid_data->last_call,
$uid_data->created_at,
$uid_data->last_call
);

return false !== $wpdb->query($sql);
}

/**
* Increments the counter for an existing rate limit record.
* Resets counter if the period has expired.
*
* @param RateLimiterDto $uid_data
* @return bool
*/
protected function increment(RateLimiterDto $uid_data): bool
{
global $wpdb;

$table_name = APBCT_TBL_RATE_LIMITS;

$is_expired = ($this->current_ts - $uid_data->created_at) > $this->config->period;

$uid_data->counter = $is_expired ? 1 : $uid_data->counter + 1;
$uid_data->created_at = $is_expired ? $this->current_ts : $uid_data->created_at;
$uid_data->last_call = $this->current_ts;

$sql = $wpdb->prepare(
"UPDATE {$table_name} SET counter = %d, last_call = %d, created_at = %d WHERE uid = %s",
$uid_data->counter,
$uid_data->last_call,
$uid_data->created_at,
$uid_data->uid
);

return false !== $wpdb->query($sql);
}

/**
* Removes expired rate limit records from the database
*
* @return bool
*/
protected function cleanUp(): bool
{
global $wpdb;

$table_name = APBCT_TBL_RATE_LIMITS;
$threshold = $this->current_ts - ($this->config->period + 10);

$sql = $wpdb->prepare(
"DELETE FROM {$table_name} WHERE created_at < %d AND type = %s",
$threshold,
$this->config->type
);

return false !== $wpdb->query($sql);
}
}
34 changes: 30 additions & 4 deletions lib/Cleantalk/ApbctWP/RemoteCalls.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
namespace Cleantalk\ApbctWP;

use Cleantalk\ApbctWP\Firewall\SFWUpdateHelper;
use Cleantalk\ApbctWP\UpdatePlugin\DbAnalyzer;
use Cleantalk\ApbctWP\RateLimit\ApbctRateLimiter;
use Cleantalk\ApbctWP\Variables\Post;
use Cleantalk\ApbctWP\Variables\Request;
use Cleantalk\ApbctWP\Variables\Get;
use Cleantalk\Common\RateLimiter\RateLimiterConfig;
use Cleantalk\Common\TT;

class RemoteCalls
Expand Down Expand Up @@ -102,6 +103,11 @@ public static function perform()
$action = strtolower(Request::getString('spbc_remote_call_action'));
$token = strtolower(Request::getString('spbc_remote_call_token'));

// Rate limit check — block abusive IPs before any cooldown logic
if ( ! self::rateLimitCheck() ) {
die('FAIL ' . json_encode(array('error' => 'RATE_LIMIT_EXCEEDED')));
}

if ( isset($apbct->remote_calls[$action]) ) {
$cooldown = isset($apbct->remote_calls[$action]['cooldown']) ? $apbct->remote_calls[$action]['cooldown'] : self::COOLDOWN;

Expand All @@ -114,9 +120,6 @@ public static function perform()
time() - $apbct->remote_calls[$action]['last_call'] >= $cooldown ||
($action === 'sfw_update' && Request::get('file_urls'))
) {
$apbct->remote_calls[$action]['last_call'] = time();
$apbct->save('remote_calls');

if ( ! self::isRcAllowed() ) {
die('FAIL ' . json_encode(array('error' => 'FORBIDDEN')));
}
Expand All @@ -126,6 +129,10 @@ public static function perform()
(self::checkToken($token)) ||
(self::isAllowedWithoutToken($action) && self::checkWithoutToken())
) {
// Update last_call only for authorized requests
$apbct->remote_calls[$action]['last_call'] = time();
$apbct->save('remote_calls');

// Flag to let plugin know that Remote Call is running.
$apbct->rc_running = true;

Expand Down Expand Up @@ -677,4 +684,23 @@ private static function hideSensitiveData($data)
}
return $data;
}

/**
* Rate limit check for remote calls.
* Blocks abusive IPs that exceed 10 requests per 60 seconds.
*
* @return bool True if request is allowed, false if rate limited
*/
private static function rateLimitCheck()
{
$config = new RateLimiterConfig('rc_remote_call', 10, 60);
$limiter = new ApbctRateLimiter($config);

// If rate limiter failed (e.g. table missing), allow the request through
if ( ! $limiter->process_ok ) {
return true;
}

return $limiter->checkPassed();
}
}
4 changes: 4 additions & 0 deletions lib/Cleantalk/ApbctWP/State.php
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,10 @@ protected function setDefinitions()
// Table with session data.
define('APBCT_SPAMSCAN_LOGS', $db_prefix . 'cleantalk_spamscan_logs');
}
if ( ! defined('APBCT_TBL_RATE_LIMITS')) {
// Table with rate limit data.
define('APBCT_TBL_RATE_LIMITS', $db_prefix . 'cleantalk_rate_limits');
}
if ( ! defined('APBCT_SELECT_LIMIT')) {
// Select limit for logs.
define('APBCT_SELECT_LIMIT', 5000);
Expand Down
64 changes: 36 additions & 28 deletions lib/Cleantalk/ApbctWP/UpdatePlugin/DbColumnCreator.php
Original file line number Diff line number Diff line change
Expand Up @@ -127,36 +127,42 @@ private function updateIndexes($schema_indexes_raw, $db_column_names, &$errors)
$db_indexes_raw = $wpdb->get_results("SHOW INDEX FROM `$this->dbTableName`", ARRAY_A);

if (is_string($schema_indexes_raw) && !empty($schema_indexes_raw)) {
// Parse index definitions from string like: "PRIMARY KEY (`id`), INDEX ( `network` , `mask` ), INDEX ( `status` )"
preg_match_all('/(?:PRIMARY\s+KEY|INDEX)\s*\([^)]+\)/i', $schema_indexes_raw, $matches);
if (isset($matches[0]) && !empty($matches[0])) {
foreach ($matches[0] as $match) {
// Extract column names from index definition
if (preg_match('/\(([^)]+)\)/', $match, $col_match) && isset($col_match[1])) {
$columns_raw = trim($col_match[1]);
$columns = preg_split('/\s*,\s*/', $columns_raw);
// Normalize column names
$normalized_columns = array();
foreach ($columns as $col) {
$normalized_columns[] = trim($col, '` ');
}
// Parse index definitions from string like: "PRIMARY KEY (`id`), UNIQUE KEY `uid` (`uid`), INDEX (`type`)"
preg_match_all('/(PRIMARY\s+KEY|UNIQUE\s+(?:KEY|INDEX)|INDEX|KEY)\s*(?:`?(\w+)`?\s*)?\(([^)]+)\)/i', $schema_indexes_raw, $matches, PREG_SET_ORDER);
if (!empty($matches)) {
foreach ($matches as $match) {
if (!isset($match[1], $match[3])) {
continue;
}
$keyword = strtoupper(trim($match[1]));
$explicit_name = !empty($match[2]) ? $match[2] : '';
$columns_raw = trim($match[3]);

// Skip PRIMARY KEY as it should already exist if table has primary key
if (stripos($match, 'PRIMARY') !== false) {
continue;
}
// Skip PRIMARY KEY as it should already exist if table has primary key
if (strpos($keyword, 'PRIMARY') !== false) {
continue;
}

if (!empty($normalized_columns)) {
$index_name = $normalized_columns[0];
$columns = preg_split('/\s*,\s*/', $columns_raw);
// Normalize column names
$normalized_columns = array();
foreach ($columns as $col) {
$normalized_columns[] = trim($col, '` ');
}

$normalized_def = 'INDEX (' . implode(',', array_map(function ($col) {
return '`' . $col . '`';
}, $normalized_columns)) . ')';
if (!empty($normalized_columns)) {
// Use explicit name if provided, otherwise first column name
$index_name = !empty($explicit_name) ? $explicit_name : $normalized_columns[0];

// Use first column as index name
$schema_indexes[$index_name] = $normalized_def;
$schema_index_names[] = $index_name;
}
$is_unique = (strpos($keyword, 'UNIQUE') !== false);
$index_type = $is_unique ? 'UNIQUE INDEX' : 'INDEX';

$normalized_def = $index_type . ' (' . implode(',', array_map(function ($col) {
return '`' . $col . '`';
}, $normalized_columns)) . ')';

$schema_indexes[$index_name] = $normalized_def;
$schema_index_names[] = $index_name;
}
}
}
Expand Down Expand Up @@ -251,7 +257,8 @@ private function updateIndexes($schema_indexes_raw, $db_column_names, &$errors)
$index_def = $schema_indexes[$index_name];
if (preg_match('/\(([^)]+)\)/', $index_def, $col_match) && isset($col_match[1])) {
$columns = trim($col_match[1]);
$sql = "ALTER TABLE `$this->dbTableName` ADD INDEX `$index_name` ($columns)";
$add_type = (stripos($index_def, 'UNIQUE') !== false) ? 'UNIQUE INDEX' : 'INDEX';
$sql = "ALTER TABLE `$this->dbTableName` ADD $add_type `$index_name` ($columns)";
$result = $wpdb->query($sql);
if ($result === false) {
$errors[] = "Failed to recreate index `$index_name`.\nQuery: $wpdb->last_query\nError: $wpdb->last_error";
Expand Down Expand Up @@ -287,7 +294,8 @@ private function updateIndexes($schema_indexes_raw, $db_column_names, &$errors)
// Parse the index definition to extract columns
if (preg_match('/\(([^)]+)\)/', $index_def, $col_match) && isset($col_match[1])) {
$columns = trim($col_match[1]);
$sql = "ALTER TABLE `$this->dbTableName` ADD INDEX `$diff_index_name` ($columns)";
$add_type = (stripos($index_def, 'UNIQUE') !== false) ? 'UNIQUE INDEX' : 'INDEX';
$sql = "ALTER TABLE `$this->dbTableName` ADD $add_type `$diff_index_name` ($columns)";
$result = $wpdb->query($sql);
if ($result === false) {
$errors[] = "Failed to add index `$diff_index_name`.\nQuery: $wpdb->last_query\nError: $wpdb->last_error";
Expand Down
4 changes: 4 additions & 0 deletions lib/Cleantalk/Common/RateLimiter/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.idea
/vendor/
/composer.lock
/tests/.phpunit.result.cache
1 change: 1 addition & 0 deletions lib/Cleantalk/Common/RateLimiter/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
How to use this app here
Comment thread
AntonV1211 marked this conversation as resolved.
Loading
Loading