diff --git a/cleantalk.php b/cleantalk.php index 95e921655..e7b61a6e1 100644 --- a/cleantalk.php +++ b/cleantalk.php @@ -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, ) diff --git a/lib/Cleantalk/ApbctWP/Firewall/AntiFlood.php b/lib/Cleantalk/ApbctWP/Firewall/AntiFlood.php index b05e4cfe3..308c47a94 100644 --- a/lib/Cleantalk/ApbctWP/Firewall/AntiFlood.php +++ b/lib/Cleantalk/ApbctWP/Firewall/AntiFlood.php @@ -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; private $apbct = array(); private $store_interval = 60; @@ -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',); @@ -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' ); @@ -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}' => '

The page was generated at ' . date('D, d M Y H:i:s') . "

", - '{COOKIE_ANTIFLOOD_PASSED}' => md5($result['ip'] . $this->api_key), + '{COOKIE_ANTIFLOOD_PASSED}' => $pass_token, '{SCRIPT_URL}' => esc_url($js_url), // Custom Logo @@ -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()); + + 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'); diff --git a/lib/Cleantalk/ApbctWP/Firewall/die_page_antiflood.html b/lib/Cleantalk/ApbctWP/Firewall/die_page_antiflood.html index faa0b12fe..eb687670b 100644 --- a/lib/Cleantalk/ApbctWP/Firewall/die_page_antiflood.html +++ b/lib/Cleantalk/ApbctWP/Firewall/die_page_antiflood.html @@ -230,7 +230,7 @@

{SFW_DIE_NOTICE_IP}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)); + } +}