Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
183 changes: 183 additions & 0 deletions lib/Cleantalk/ApbctWP/RateLimit/ApbctRateLimiter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
<?php

namespace Cleantalk\ApbctWP\RateLimit;

use Cleantalk\Common\RateLimiter\RateLimiter;
use Cleantalk\Common\RateLimiter\RateLimiterConfig;
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
*
* @psalm-suppress MethodSignatureMismatch - library uses incorrect casing RateLimiterDTO vs RateLimiterDto
*/
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
*
* @psalm-suppress MethodSignatureMismatch - library uses incorrect casing RateLimiterDTO vs RateLimiterDto
*/
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);
}
}
33 changes: 30 additions & 3 deletions lib/Cleantalk/ApbctWP/RemoteCalls.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
namespace Cleantalk\ApbctWP;

use Cleantalk\ApbctWP\Firewall\SFWUpdateHelper;
use Cleantalk\ApbctWP\RateLimit\ApbctRateLimiter;
use Cleantalk\ApbctWP\UpdatePlugin\DbAnalyzer;
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 +104,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 +121,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 +130,10 @@ public static function perform()
(self::checkToken($token)) ||
(self::isAllowedWithoutToken($action) && self::checkWithoutToken())
) {
// Update last_call only for authenticated requests
Comment thread
AntonV1211 marked this conversation as resolved.
Outdated
$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 +685,23 @@ private static function hideSensitiveData($data)
}
return $data;
}

/**
* Rate limit check for remote calls.
* Blocks abusive IPs/UAs that exceed 10 requests per 60 seconds.
*
Comment thread
AntonV1211 marked this conversation as resolved.
* @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
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