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
1 change: 0 additions & 1 deletion cleantalk.php
Original file line number Diff line number Diff line change
Expand Up @@ -1001,7 +1001,6 @@ function apbct_sfw__check()
APBCT_TBL_FIREWALL_LOG,
APBCT_TBL_AC_LOG,
array(
'api_key' => $apbct->api_key,
'view_limit' => $apbct->settings['sfw__anti_flood__view_limit'],
'apbct' => $apbct,
)
Expand Down
68 changes: 65 additions & 3 deletions lib/Cleantalk/ApbctWP/Firewall/AntiFlood.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,13 @@
*/
class AntiFlood extends \Cleantalk\Common\Firewall\FirewallModule
{
const PASS_TOKEN_TTL = 60;

public $module_name = 'ANTIFLOOD';

private $db__table__ac_logs;
private $db__table__ac_ua_bl;

private $api_key = '';
private $view_limit = 20;
Comment thread
Glomberg marked this conversation as resolved.
private $apbct = array();
private $store_interval = 60;
Expand Down Expand Up @@ -112,13 +113,14 @@ public function check()
}

// Passed
if (Cookie::get('apbct_antiflood_passed') === md5($current_ip . $this->api_key)) {
if ($this->consumePassToken(Cookie::get('apbct_antiflood_passed'), $current_ip)) {
if ( ! headers_sent()) {
Cookie::set('apbct_antiflood_passed', '0', time() - 86400, '/', '', null, true);
}

// Do logging an one passed request
$this->updateLog($current_ip, 'PASS_ANTIFLOOD');
$this->updateAcLog();

$results[] = array('ip' => $current_ip, 'is_personal' => false, 'status' => 'PASS_ANTIFLOOD',);

Expand Down Expand Up @@ -229,6 +231,7 @@ public function diePage($result)

// File exists?
if (file_exists(CLEANTALK_PLUGIN_DIR . 'lib/Cleantalk/ApbctWP/Firewall/die_page_antiflood.html')) {
$pass_token = $this->createPassToken($result['ip']);
$this->sfw_die_page = file_get_contents(
CLEANTALK_PLUGIN_DIR . 'lib/Cleantalk/ApbctWP/Firewall/die_page_antiflood.html'
);
Expand Down Expand Up @@ -269,7 +272,7 @@ public function diePage($result)
'{SERVICE_ID}' => esc_html($this->apbct->data['service_id']) . ', ' . esc_html($net_count),
'{HOST}' => get_home_url() . ', ' . APBCT_VERSION,
'{GENERATED}' => '<p>The page was generated at&nbsp;' . date('D, d M Y H:i:s') . "</p>",
'{COOKIE_ANTIFLOOD_PASSED}' => md5($result['ip'] . $this->api_key),
'{COOKIE_ANTIFLOOD_PASSED}' => $pass_token,
'{SCRIPT_URL}' => esc_url($js_url),

// Custom Logo
Expand Down Expand Up @@ -332,6 +335,65 @@ public function printDiePage()
die("IP BLACKLISTED. Blocked by AntiFlood " . $this->apbct->stats['last_sfw_block']['ip']);
}

/**
* Issue a short-lived pass token and persist it for a single atomic use.
*
* @param string $ip
* @return string
*/
private function createPassToken($ip)
{
if (function_exists('random_bytes')) {
$token = bin2hex(random_bytes(32));
} else {
$token = hash('sha256', wp_generate_password(64, false, false));
}

$token_id = md5('antiflood_pass:' . $token);
$expires = time() + self::PASS_TOKEN_TTL;
$query = "INSERT INTO " . $this->db__table__ac_logs . "
SET id = %s, ip = %s, entries = 0, interval_start = %d, ua = ''";

$this->db->prepare($query, array($token_id, $ip, $expires));
$result = $this->db->execute($this->db->getQuery());
Comment thread
Glomberg marked this conversation as resolved.

if ( ! is_int($result) || $result < 1) {
error_log('CleanTalk AntiFlood: failed to store a one-time pass token.');
return '';
}

return $token;
}

/**
* Atomically consume a pass token bound to the issuing IP.
*
* @param mixed $token
* @param string $ip
* @return bool
*/
private function consumePassToken($token, $ip)
{
if ( ! is_string($token) || ! preg_match('/^[a-f0-9]{64}$/i', $token)) {
return false;
}

$token_id = md5('antiflood_pass:' . strtolower($token));
$query = "DELETE FROM " . $this->db__table__ac_logs . "
WHERE id = %s AND ip = %s AND interval_start > %d AND entries = 0
LIMIT 1";

$this->db->prepare($query, array($token_id, $ip, time()));
$result = $this->db->execute($this->db->getQuery());

if ($result === false) {
error_log('CleanTalk AntiFlood: failed to consume a one-time pass token.');
return false;
}

return $result === 1;
}

private function checkExclusions()
{
$allowed_roles = array('administrator', 'editor');
Expand Down
2 changes: 1 addition & 1 deletion lib/Cleantalk/ApbctWP/Firewall/die_page_antiflood.html
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ <h1 class='main'>{SFW_DIE_NOTICE_IP}<a href='https://cleantalk.org/blacklists/{R

setTimeout( function(){
var date = new Date;
date.setDate(date.getDate() + 30);
date.setTime(date.getTime() + 60000);
ctSetCookie(
[
['apbct_antiflood_passed', escape('{COOKIE_ANTIFLOOD_PASSED}'), date.toUTCString()]
Expand Down
131 changes: 131 additions & 0 deletions tests/ApbctWP/Firewall/TestAntiFlood.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
<?php

use Cleantalk\ApbctWP\Firewall\AntiFlood;
use Cleantalk\ApbctWP\Variables\Cookie;
use PHPUnit\Framework\TestCase;

class TestAntiFlood extends TestCase
{
private $initialApbct;
private $initialCookie;
private $initialCookieVariables;
private $initialHttpUserAgent;

public function setUp(): void
{
global $apbct;

$this->initialApbct = isset($apbct) ? $apbct : null;
$this->initialCookie = $_COOKIE;
$this->initialCookieVariables = Cookie::getInstance()->variables;
$this->initialHttpUserAgent = isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : null;

$apbct = (object) array(
'key_is_ok' => 1,
'api_key' => 'test_api_key',
'data' => array(
'key_is_ok' => 1,
'cookies_type' => 'native',
),
);

$_COOKIE = array();
Cookie::getInstance()->variables = array();
$_SERVER['HTTP_USER_AGENT'] = 'phpunit-agent';
}

public function tearDown(): void
{
global $apbct;

if ($this->initialApbct === null) {
unset($apbct);
} else {
$apbct = $this->initialApbct;
}

$_COOKIE = $this->initialCookie;
Cookie::getInstance()->variables = $this->initialCookieVariables;

if ($this->initialHttpUserAgent === null) {
unset($_SERVER['HTTP_USER_AGENT']);
} else {
$_SERVER['HTTP_USER_AGENT'] = $this->initialHttpUserAgent;
}
}

public function testPassTokenCanBeConsumedOnlyOnceAndCountsTowardTheLimit(): void
{
$prepared_query = '';
$prepared_vars = array();
$pass_consumptions = 0;
$executed_queries = array();

$db = $this->getMockBuilder(stdClass::class)
->setMethods(array('fetchAll', 'prepare', 'getQuery', 'execute'))
->getMock();

$db->method('fetchAll')->willReturnCallback(function ($query) {
return strpos($query, 'ORDER BY') !== false
? array()
: array(array('total_count' => 1));
});
$db->method('prepare')->willReturnCallback(function ($query, $vars) use (&$prepared_query, &$prepared_vars) {
$prepared_query = $query;
$prepared_vars = $vars;
});
$db->method('getQuery')->willReturnCallback(function () use (&$prepared_query) {
return $prepared_query;
});
$db->method('execute')->willReturnCallback(function ($query) use (
&$pass_consumptions,
&$executed_queries,
&$prepared_query,
&$prepared_vars
) {
$executed_queries[] = $query;

if (strpos($query, 'DELETE FROM ac_logs') !== false) {
$pass_consumptions++;
return $pass_consumptions === 1 ? 1 : 0;
}

return 1;
});

$module = new AntiFlood(
'firewall_log',
'ac_logs',
array(
'api_key' => 'test_api_key',
'view_limit' => 1,
'chance_to_clean' => 0,
)
);
$module->setDb($db);
$module->setIpArray(array('real' => '203.0.113.10'));

$create_token = new ReflectionMethod(AntiFlood::class, 'createPassToken');
$create_token->setAccessible(true);
$token = $create_token->invoke($module, '203.0.113.10');

$this->assertSame(1, preg_match('/^[a-f0-9]{64}$/', $token));
$this->assertStringContainsString('INSERT INTO ac_logs', $prepared_query);
$this->assertSame(md5('antiflood_pass:' . $token), $prepared_vars[0]);
$this->assertSame('203.0.113.10', $prepared_vars[1]);

$_COOKIE[apbct__get_cookie_prefix() . 'apbct_antiflood_passed'] = $token;

$first_result = $module->check();
$this->assertSame('PASS_ANTIFLOOD', $first_result[0]['status']);
$this->assertSame(1, $pass_consumptions);

// A new request can replay the browser cookie, but the server-side row is gone.
Cookie::getInstance()->variables = array();
$second_result = $module->check();

$this->assertSame('DENY_ANTIFLOOD', $second_result[0]['status']);
$this->assertSame(2, $pass_consumptions);
$this->assertStringContainsString('entries = 1', implode("\n", $executed_queries));
}
}
Loading