From 1273add3f946883a9e072e7ef1de2a6b0f2fb7dd Mon Sep 17 00:00:00 2001 From: Glomberg Date: Fri, 26 Jun 2026 12:04:22 +0300 Subject: [PATCH 01/35] Fix. SFW. Modules die pages fixed. --- .../ApbctWP/Firewall/AntiCrawler.php | 14 ++++----- lib/Cleantalk/ApbctWP/Firewall/AntiFlood.php | 12 ++++---- lib/Cleantalk/ApbctWP/Firewall/SFW.php | 30 +++++++++---------- 3 files changed, 28 insertions(+), 28 deletions(-) diff --git a/lib/Cleantalk/ApbctWP/Firewall/AntiCrawler.php b/lib/Cleantalk/ApbctWP/Firewall/AntiCrawler.php index de4fee19a..32146473c 100644 --- a/lib/Cleantalk/ApbctWP/Firewall/AntiCrawler.php +++ b/lib/Cleantalk/ApbctWP/Firewall/AntiCrawler.php @@ -575,15 +575,15 @@ public function diePage($result) 3 ) . '
' . __('Don\'t close this page. Please, wait for 3 seconds to pass to the page.', 'cleantalk-spam-protect'), - '{CLEANTALK_TITLE}' => $apbct->data['wl_brandname'], - '{CLEANTALK_URL}' => $apbct->data['wl_url'], - '{REMOTE_ADDRESS}' => $ip, - '{SERVICE_ID}' => $this->apbct->data['service_id'] . ', ' . $net_count, + '{CLEANTALK_TITLE}' => esc_html($apbct->data['wl_brandname']), + '{CLEANTALK_URL}' => esc_html($apbct->data['wl_url']), + '{REMOTE_ADDRESS}' => esc_html($ip), + '{SERVICE_ID}' => esc_html($this->apbct->data['service_id']) . ', ' . esc_html($net_count), '{HOST}' => get_home_url() . ', ' . APBCT_VERSION, '{COOKIE_ANTICRAWLER}' => hash('sha256', $apbct->api_key . $apbct->data['salt']), '{COOKIE_ANTICRAWLER_PASSED}' => '1', '{GENERATED}' => '

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

", - '{SCRIPT_URL}' => $js_url, + '{SCRIPT_URL}' => esc_html($js_url), // Custom Logo '{CUSTOM_LOGO}' => $custom_logo_img @@ -599,9 +599,9 @@ public function diePage($result) . '

$_SERVER

' . str_replace("\n", "
", print_r($_SERVER, true)) . '

AC_LOG_RESULT

' - . str_replace("\n", "
", print_r($this->ac_log_result, true)) + . esc_html(str_replace("\n", "
", print_r($this->ac_log_result, true))) . '

IPS

' - . str_replace("\n", "
", print_r($this->ip_array, true)); + . esc_html(str_replace("\n", "
", print_r($this->ip_array, true))); } else { $debug = ''; } diff --git a/lib/Cleantalk/ApbctWP/Firewall/AntiFlood.php b/lib/Cleantalk/ApbctWP/Firewall/AntiFlood.php index ab68f8fb5..cec33bfa7 100644 --- a/lib/Cleantalk/ApbctWP/Firewall/AntiFlood.php +++ b/lib/Cleantalk/ApbctWP/Firewall/AntiFlood.php @@ -258,15 +258,15 @@ public function diePage($result) ), 30 ), - '{CLEANTALK_TITLE}' => $apbct->data['wl_brandname'], - '{CLEANTALK_URL}' => $apbct->data['wl_url'], - '{REMOTE_ADDRESS}' => $result['ip'], - '{REQUEST_URI}' => Server::get('REQUEST_URI'), - '{SERVICE_ID}' => $this->apbct->data['service_id'] . ', ' . $net_count, + '{CLEANTALK_TITLE}' => esc_html($apbct->data['wl_brandname']), + '{CLEANTALK_URL}' => esc_html($apbct->data['wl_url']), + '{REMOTE_ADDRESS}' => esc_html($result['ip']), + '{REQUEST_URI}' => esc_html(Server::getString('REQUEST_URI')), + '{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), - '{SCRIPT_URL}' => $js_url, + '{SCRIPT_URL}' => esc_html($js_url), // Custom Logo '{CUSTOM_LOGO}' => $custom_logo_img diff --git a/lib/Cleantalk/ApbctWP/Firewall/SFW.php b/lib/Cleantalk/ApbctWP/Firewall/SFW.php index 5a33977ba..66f473b47 100644 --- a/lib/Cleantalk/ApbctWP/Firewall/SFW.php +++ b/lib/Cleantalk/ApbctWP/Firewall/SFW.php @@ -398,7 +398,7 @@ public function diePage($result) $block_message = sprintf( esc_html__('SpamFireWall is checking your browser and IP %s for spam bots', 'cleantalk-spam-protect'), - '' . $result['ip'] . '' + '' . esc_html($result['ip']) . '' ); $request_uri = $this->server__request_uri; @@ -437,16 +437,16 @@ public function diePage($result) 'This is the testing page for SpamFireWall', 'cleantalk-spam-protect' ) : ''), - '{CLEANTALK_URL}' => $apbct->data['wl_url'], - '{REMOTE_ADDRESS}' => $result['ip'], - '{SERVICE_ID}' => $apbct->data['service_id'] . ', ' . $net_count, + '{CLEANTALK_URL}' => esc_html($apbct->data['wl_url']), + '{REMOTE_ADDRESS}' => esc_html($result['ip']), + '{SERVICE_ID}' => esc_html($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') . '

', - '{REQUEST_URI}' => $request_uri, + '{REQUEST_URI}' => esc_html($request_uri), // Cookie '{COOKIE_PREFIX}' => '', - '{COOKIE_DOMAIN}' => $this->cookie_domain, + '{COOKIE_DOMAIN}' => esc_html($this->cookie_domain), '{COOKIE_SFW}' => $cookie_val, '{COOKIE_ANTICRAWLER}' => hash('sha256', $apbct->api_key . $apbct->data['salt']), @@ -456,13 +456,13 @@ public function diePage($result) '{TEST_IP__HEADER}' => '', '{TEST_IP}' => '', '{REAL_IP}' => '', - '{SCRIPT_URL}' => $js_url, + '{SCRIPT_URL}' => esc_html($js_url), // Message about IP status '{MESSAGE_IP_STATUS}' => '', // Custom Logo - '{CUSTOM_LOGO}' => $custom_logo_img + '{CUSTOM_LOGO}' => esc_attr($custom_logo_img) ); /** @@ -491,7 +491,7 @@ public function diePage($result) break; } - $replaces['{MESSAGE_IP_STATUS}'] = "

$message_ip_status

"; + $replaces['{MESSAGE_IP_STATUS}'] = "

" . esc_html($message_ip_status) . "

"; } // Test @@ -502,8 +502,8 @@ public function diePage($result) ); $replaces['{REAL_IP__HEADER}'] = 'Real IP:'; $replaces['{TEST_IP__HEADER}'] = 'Test IP:'; - $replaces['{TEST_IP}'] = $this->test_ip; - $replaces['{REAL_IP}'] = $this->real_ip; + $replaces['{TEST_IP}'] = esc_html($this->test_ip); + $replaces['{REAL_IP}'] = esc_html($this->real_ip); } // Debug @@ -511,13 +511,13 @@ public function diePage($result) $debug = '

Headers

' . var_export(apache_request_headers(), true) . '

REMOTE_ADDR

' - . $this->server__remote_addr + . esc_html($this->server__remote_addr) . '

SERVER_ADDR

' - . $this->server__remote_addr + . esc_html($this->server__remote_addr) . '

IP_ARRAY

' - . var_export($this->ip_array, true) + . esc_html(var_export($this->ip_array, true)) . '

ADDITIONAL

' - . var_export($this->debug_data, true); + . esc_html(var_export($this->debug_data, true)); } $replaces['{DEBUG}'] = isset($debug) ? $debug : ''; From 4e956ba2ea2dbb865a666979fc234ed57dc38a7e Mon Sep 17 00:00:00 2001 From: Viktor Date: Tue, 7 Jul 2026 10:34:54 +0300 Subject: [PATCH 02/35] Fix. Code. Copilot comment https://github.com/CleanTalk/wordpress-antispam/pull/820#discussion_r3480337358 fixed. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- lib/Cleantalk/ApbctWP/Firewall/SFW.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Cleantalk/ApbctWP/Firewall/SFW.php b/lib/Cleantalk/ApbctWP/Firewall/SFW.php index 66f473b47..b334cc31a 100644 --- a/lib/Cleantalk/ApbctWP/Firewall/SFW.php +++ b/lib/Cleantalk/ApbctWP/Firewall/SFW.php @@ -456,7 +456,7 @@ public function diePage($result) '{TEST_IP__HEADER}' => '', '{TEST_IP}' => '', '{REAL_IP}' => '', - '{SCRIPT_URL}' => esc_html($js_url), +'{SCRIPT_URL}' => esc_url($js_url), // Message about IP status '{MESSAGE_IP_STATUS}' => '', From c1fd9d8ba2684e09dbbee9910bdf72ec0f64e032 Mon Sep 17 00:00:00 2001 From: Viktor Date: Tue, 7 Jul 2026 10:35:26 +0300 Subject: [PATCH 03/35] Fix. Code. Copilot comment https://github.com/CleanTalk/wordpress-antispam/pull/820#discussion_r3480337377 fixed. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- lib/Cleantalk/ApbctWP/Firewall/SFW.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Cleantalk/ApbctWP/Firewall/SFW.php b/lib/Cleantalk/ApbctWP/Firewall/SFW.php index b334cc31a..982093b19 100644 --- a/lib/Cleantalk/ApbctWP/Firewall/SFW.php +++ b/lib/Cleantalk/ApbctWP/Firewall/SFW.php @@ -462,7 +462,7 @@ public function diePage($result) '{MESSAGE_IP_STATUS}' => '', // Custom Logo - '{CUSTOM_LOGO}' => esc_attr($custom_logo_img) +'{CUSTOM_LOGO}' => wp_kses_post($custom_logo_img) ); /** From c56bae88b214bad8a318595e93f79e6077964213 Mon Sep 17 00:00:00 2001 From: Viktor Date: Tue, 7 Jul 2026 10:36:01 +0300 Subject: [PATCH 04/35] Fix. Code. Copilot comment https://github.com/CleanTalk/wordpress-antispam/pull/820#discussion_r3480337395 fixed. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- lib/Cleantalk/ApbctWP/Firewall/SFW.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Cleantalk/ApbctWP/Firewall/SFW.php b/lib/Cleantalk/ApbctWP/Firewall/SFW.php index 982093b19..b5689e2b0 100644 --- a/lib/Cleantalk/ApbctWP/Firewall/SFW.php +++ b/lib/Cleantalk/ApbctWP/Firewall/SFW.php @@ -509,7 +509,7 @@ public function diePage($result) // Debug if ($this->debug) { $debug = '

Headers

' - . var_export(apache_request_headers(), true) + . '
' . esc_html(var_export(apache_request_headers(), true)) . '
' . '

REMOTE_ADDR

' . esc_html($this->server__remote_addr) . '

SERVER_ADDR

' From 53480932af5cf6383cfcc9b1e262c2e24993fb59 Mon Sep 17 00:00:00 2001 From: Viktor Date: Tue, 7 Jul 2026 10:36:30 +0300 Subject: [PATCH 05/35] Fix. Code. Copilot comment https://github.com/CleanTalk/wordpress-antispam/pull/820#discussion_r3480337412 fixed. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- lib/Cleantalk/ApbctWP/Firewall/AntiFlood.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Cleantalk/ApbctWP/Firewall/AntiFlood.php b/lib/Cleantalk/ApbctWP/Firewall/AntiFlood.php index cec33bfa7..eb076d4fb 100644 --- a/lib/Cleantalk/ApbctWP/Firewall/AntiFlood.php +++ b/lib/Cleantalk/ApbctWP/Firewall/AntiFlood.php @@ -259,7 +259,7 @@ public function diePage($result) 30 ), '{CLEANTALK_TITLE}' => esc_html($apbct->data['wl_brandname']), - '{CLEANTALK_URL}' => esc_html($apbct->data['wl_url']), +'{CLEANTALK_URL}' => esc_url($apbct->data['wl_url']), '{REMOTE_ADDRESS}' => esc_html($result['ip']), '{REQUEST_URI}' => esc_html(Server::getString('REQUEST_URI')), '{SERVICE_ID}' => esc_html($this->apbct->data['service_id']) . ', ' . esc_html($net_count), From a82a1c50e20776873d1488368aeca3d07c8973a3 Mon Sep 17 00:00:00 2001 From: Viktor Date: Tue, 7 Jul 2026 10:36:56 +0300 Subject: [PATCH 06/35] Fix. Code. Copilot comment https://github.com/CleanTalk/wordpress-antispam/pull/820#discussion_r3480337428 fixed. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- lib/Cleantalk/ApbctWP/Firewall/AntiFlood.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Cleantalk/ApbctWP/Firewall/AntiFlood.php b/lib/Cleantalk/ApbctWP/Firewall/AntiFlood.php index eb076d4fb..c49a0fa96 100644 --- a/lib/Cleantalk/ApbctWP/Firewall/AntiFlood.php +++ b/lib/Cleantalk/ApbctWP/Firewall/AntiFlood.php @@ -266,7 +266,7 @@ public function diePage($result) '{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), - '{SCRIPT_URL}' => esc_html($js_url), +'{SCRIPT_URL}' => esc_url($js_url), // Custom Logo '{CUSTOM_LOGO}' => $custom_logo_img From 97bb54811ff5fbc825afeda5b587e9b54508f28a Mon Sep 17 00:00:00 2001 From: Viktor Date: Tue, 7 Jul 2026 10:37:18 +0300 Subject: [PATCH 07/35] Fix. Code. Copilot comment https://github.com/CleanTalk/wordpress-antispam/pull/820#discussion_r3480337454 fixed. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- lib/Cleantalk/ApbctWP/Firewall/AntiCrawler.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Cleantalk/ApbctWP/Firewall/AntiCrawler.php b/lib/Cleantalk/ApbctWP/Firewall/AntiCrawler.php index 32146473c..998730ced 100644 --- a/lib/Cleantalk/ApbctWP/Firewall/AntiCrawler.php +++ b/lib/Cleantalk/ApbctWP/Firewall/AntiCrawler.php @@ -576,7 +576,7 @@ public function diePage($result) ) . '
' . __('Don\'t close this page. Please, wait for 3 seconds to pass to the page.', 'cleantalk-spam-protect'), '{CLEANTALK_TITLE}' => esc_html($apbct->data['wl_brandname']), - '{CLEANTALK_URL}' => esc_html($apbct->data['wl_url']), +'{CLEANTALK_URL}' => esc_url($apbct->data['wl_url']), '{REMOTE_ADDRESS}' => esc_html($ip), '{SERVICE_ID}' => esc_html($this->apbct->data['service_id']) . ', ' . esc_html($net_count), '{HOST}' => get_home_url() . ', ' . APBCT_VERSION, From 49bdd823e51e957e50aba56e7b1a9dd97cc6569c Mon Sep 17 00:00:00 2001 From: Viktor Date: Tue, 7 Jul 2026 10:37:39 +0300 Subject: [PATCH 08/35] Fix. Code. Copilot comment https://github.com/CleanTalk/wordpress-antispam/pull/820#discussion_r3480337485 fixed. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- lib/Cleantalk/ApbctWP/Firewall/AntiCrawler.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Cleantalk/ApbctWP/Firewall/AntiCrawler.php b/lib/Cleantalk/ApbctWP/Firewall/AntiCrawler.php index 998730ced..c5d71b592 100644 --- a/lib/Cleantalk/ApbctWP/Firewall/AntiCrawler.php +++ b/lib/Cleantalk/ApbctWP/Firewall/AntiCrawler.php @@ -583,7 +583,7 @@ public function diePage($result) '{COOKIE_ANTICRAWLER}' => hash('sha256', $apbct->api_key . $apbct->data['salt']), '{COOKIE_ANTICRAWLER_PASSED}' => '1', '{GENERATED}' => '

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

", - '{SCRIPT_URL}' => esc_html($js_url), +'{SCRIPT_URL}' => esc_url($js_url), // Custom Logo '{CUSTOM_LOGO}' => $custom_logo_img From 2a69499986a0ac804e886f4d8d6b4c06e286381c Mon Sep 17 00:00:00 2001 From: Glomberg Date: Tue, 7 Jul 2026 10:25:19 +0300 Subject: [PATCH 09/35] Fix. Code. Copilot notice https://github.com/CleanTalk/wordpress-antispam/pull/820#discussion_r3480337333 fixed. --- lib/Cleantalk/ApbctWP/Firewall/SFW.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Cleantalk/ApbctWP/Firewall/SFW.php b/lib/Cleantalk/ApbctWP/Firewall/SFW.php index b5689e2b0..e11ee42f6 100644 --- a/lib/Cleantalk/ApbctWP/Firewall/SFW.php +++ b/lib/Cleantalk/ApbctWP/Firewall/SFW.php @@ -437,7 +437,7 @@ public function diePage($result) 'This is the testing page for SpamFireWall', 'cleantalk-spam-protect' ) : ''), - '{CLEANTALK_URL}' => esc_html($apbct->data['wl_url']), + '{CLEANTALK_URL}' => esc_url($apbct->data['wl_url']), '{REMOTE_ADDRESS}' => esc_html($result['ip']), '{SERVICE_ID}' => esc_html($apbct->data['service_id']) . ', ' . esc_html($net_count), '{HOST}' => get_home_url() . ', ' . APBCT_VERSION, From 1563703edfb3b52c93e660dba5633c45233fb740 Mon Sep 17 00:00:00 2001 From: Glomberg Date: Fri, 26 Jun 2026 13:57:45 +0300 Subject: [PATCH 10/35] Fix. SFW. RSS exclusion fixed. --- cleantalk.php | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/cleantalk.php b/cleantalk.php index 51cec47fc..1b548c318 100644 --- a/cleantalk.php +++ b/cleantalk.php @@ -887,12 +887,15 @@ function apbct_sfw__check() global $apbct, $spbc, $cleantalk_url_exclusions; // Turn off the SpamFireWall if current url in the exceptions list and WordPress core pages + $core_page_to_skip_check = array('/feed'); if ( ! empty($cleantalk_url_exclusions) && is_array($cleantalk_url_exclusions) ) { - $core_page_to_skip_check = array('/feed'); - foreach ( array_merge($cleantalk_url_exclusions, $core_page_to_skip_check) as $v ) { - if ( apbct_is_in_uri($v) ) { - return; - } + $cleantalk_url_exclusions = array_merge($cleantalk_url_exclusions, $core_page_to_skip_check); + } else { + $cleantalk_url_exclusions = $core_page_to_skip_check; + } + foreach ( $cleantalk_url_exclusions as $v ) { + if ( apbct_is_in_uri($v) ) { + return; } } From c99b0fc8d8aa4ebf73298d0073a29c94b1aa09a0 Mon Sep 17 00:00:00 2001 From: AntonV1211 Date: Mon, 29 Jun 2026 17:07:33 +0700 Subject: [PATCH 11/35] Fix. SFW. Update SFW fix --- .../ApbctWP/HTTP/HTTPMultiRequestService.php | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/lib/Cleantalk/ApbctWP/HTTP/HTTPMultiRequestService.php b/lib/Cleantalk/ApbctWP/HTTP/HTTPMultiRequestService.php index 25ffdffaa..68b6aa573 100644 --- a/lib/Cleantalk/ApbctWP/HTTP/HTTPMultiRequestService.php +++ b/lib/Cleantalk/ApbctWP/HTTP/HTTPMultiRequestService.php @@ -131,18 +131,18 @@ public function executeMultiContract() */ public function fillMultiContract($http_multi_result) { - // Handle HTTP request error - if (!empty($http_multi_result['error'])) { - $this->error_msg = __CLASS__ . ': HTTP_MULTI_RESULT ERROR' . $http_multi_result['error']; - return $this; - } - // Validate result is an array if (!is_array($http_multi_result)) { $this->error_msg = __CLASS__ . ': HTTP_MULTI_RESULT INVALID'; return $this; } + // Handle HTTP request error + if (!empty($http_multi_result['error'])) { + $this->error_msg = __CLASS__ . ': HTTP_MULTI_RESULT ERROR' . $http_multi_result['error']; + return $this; + } + // Fill each contract with corresponding response data foreach ($this->contracts as $contract) { if (isset($http_multi_result[$contract->url])) { @@ -213,7 +213,15 @@ public function sendRequests($urls) // Configure and execute multi-request $http->setUrl($urls) ->setPresets('get'); - return $http->request(); + $result = $http->request(); + + // CommonRequest::request() returns a single value instead of an associative + // array when only one URL is passed. Normalize to expected format. + if (count($urls) === 1 && !is_array($result)) { + return [$urls[0] => $result]; + } + + return $result; } /** From d89417e77df8e430ac04d6b9efd2902c95251271 Mon Sep 17 00:00:00 2001 From: AntonV1211 Date: Mon, 29 Jun 2026 17:55:24 +0700 Subject: [PATCH 12/35] fix test error --- lib/Cleantalk/ApbctWP/HTTP/HTTPMultiRequestService.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Cleantalk/ApbctWP/HTTP/HTTPMultiRequestService.php b/lib/Cleantalk/ApbctWP/HTTP/HTTPMultiRequestService.php index 68b6aa573..f0d8cc509 100644 --- a/lib/Cleantalk/ApbctWP/HTTP/HTTPMultiRequestService.php +++ b/lib/Cleantalk/ApbctWP/HTTP/HTTPMultiRequestService.php @@ -217,7 +217,7 @@ public function sendRequests($urls) // CommonRequest::request() returns a single value instead of an associative // array when only one URL is passed. Normalize to expected format. - if (count($urls) === 1 && !is_array($result)) { + if (count($urls) === 1 && !is_array($result) && isset($urls[0])) { return [$urls[0] => $result]; } From df9dfe23cbd5baf5e2d114e71138da5bc2d98ad0 Mon Sep 17 00:00:00 2001 From: AntonV1211 Date: Mon, 29 Jun 2026 18:10:10 +0700 Subject: [PATCH 13/35] fix review copilot --- lib/Cleantalk/ApbctWP/HTTP/HTTPMultiRequestService.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/Cleantalk/ApbctWP/HTTP/HTTPMultiRequestService.php b/lib/Cleantalk/ApbctWP/HTTP/HTTPMultiRequestService.php index f0d8cc509..0ef750d44 100644 --- a/lib/Cleantalk/ApbctWP/HTTP/HTTPMultiRequestService.php +++ b/lib/Cleantalk/ApbctWP/HTTP/HTTPMultiRequestService.php @@ -139,7 +139,7 @@ public function fillMultiContract($http_multi_result) // Handle HTTP request error if (!empty($http_multi_result['error'])) { - $this->error_msg = __CLASS__ . ': HTTP_MULTI_RESULT ERROR' . $http_multi_result['error']; + $this->error_msg = __CLASS__ . ': HTTP_MULTI_RESULT ERROR: ' . $http_multi_result['error']; return $this; } @@ -214,11 +214,12 @@ public function sendRequests($urls) $http->setUrl($urls) ->setPresets('get'); $result = $http->request(); + $url_list = is_array($urls) ? array_values($urls) : [$urls]; // CommonRequest::request() returns a single value instead of an associative // array when only one URL is passed. Normalize to expected format. - if (count($urls) === 1 && !is_array($result) && isset($urls[0])) { - return [$urls[0] => $result]; + if (count($url_list) === 1 && !is_array($result)) { + return [$url_list[0] => $result]; } return $result; From 7c2929df3ad3215f8675b80a5252e017ac8c0509 Mon Sep 17 00:00:00 2001 From: AntonV1211 Date: Mon, 29 Jun 2026 18:40:54 +0700 Subject: [PATCH 14/35] Fix. SFW. Update SFW fix --- .../ApbctWP/HTTP/HTTPMultiRequestService.php | 11 +---------- lib/Cleantalk/Common/HTTP/Request.php | 4 ++-- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/lib/Cleantalk/ApbctWP/HTTP/HTTPMultiRequestService.php b/lib/Cleantalk/ApbctWP/HTTP/HTTPMultiRequestService.php index 0ef750d44..997bdd638 100644 --- a/lib/Cleantalk/ApbctWP/HTTP/HTTPMultiRequestService.php +++ b/lib/Cleantalk/ApbctWP/HTTP/HTTPMultiRequestService.php @@ -213,16 +213,7 @@ public function sendRequests($urls) // Configure and execute multi-request $http->setUrl($urls) ->setPresets('get'); - $result = $http->request(); - $url_list = is_array($urls) ? array_values($urls) : [$urls]; - - // CommonRequest::request() returns a single value instead of an associative - // array when only one URL is passed. Normalize to expected format. - if (count($url_list) === 1 && !is_array($result)) { - return [$url_list[0] => $result]; - } - - return $result; + return $http->request(); } /** diff --git a/lib/Cleantalk/Common/HTTP/Request.php b/lib/Cleantalk/Common/HTTP/Request.php index b7e7db7c9..ee16ab029 100644 --- a/lib/Cleantalk/Common/HTTP/Request.php +++ b/lib/Cleantalk/Common/HTTP/Request.php @@ -366,8 +366,8 @@ protected function runCallbacks() } unset($response); - // Return a single content if it was a single request - return is_array($this->response) && count($this->response) > 1 + // Return associative array if URLs were passed as array, single value otherwise + return is_array($this->url) ? $return_value : reset($return_value); } From c6b1e29e35df19c27475001d72d34385b49959a7 Mon Sep 17 00:00:00 2001 From: AntonV1211 Date: Fri, 26 Jun 2026 16:17:23 +0700 Subject: [PATCH 15/35] New. RateLimit. Enabled the RateLimit shared library --- composer.json | 3 + lib/Cleantalk/Common/RateLimiter/.gitignore | 4 + lib/Cleantalk/Common/RateLimiter/README.md | 1 + .../Common/RateLimiter/RateLimiter.php | 223 ++++++++++++++++++ .../Common/RateLimiter/RateLimiterConfig.php | 50 ++++ .../Common/RateLimiter/RateLimiterDto.php | 94 ++++++++ .../Common/RateLimiter/composer.json | 39 +++ .../Common/RateLimiter/tests/.phpcs.xml | 30 +++ .../Common/RateLimiter/tests/phpunit.xml | 16 ++ .../Common/RateLimiter/tests/psalm.xml | 29 +++ lib/Cleantalk/Common/Templates/.gitignore | 3 + lib/Cleantalk/Common/Templates/Dto.php | 59 +++++ lib/Cleantalk/Common/Templates/Multiton.php | 28 +++ lib/Cleantalk/Common/Templates/Singleton.php | 47 ++++ lib/Cleantalk/Common/Templates/composer.json | 16 ++ 15 files changed, 642 insertions(+) create mode 100644 lib/Cleantalk/Common/RateLimiter/.gitignore create mode 100644 lib/Cleantalk/Common/RateLimiter/README.md create mode 100644 lib/Cleantalk/Common/RateLimiter/RateLimiter.php create mode 100644 lib/Cleantalk/Common/RateLimiter/RateLimiterConfig.php create mode 100644 lib/Cleantalk/Common/RateLimiter/RateLimiterDto.php create mode 100644 lib/Cleantalk/Common/RateLimiter/composer.json create mode 100644 lib/Cleantalk/Common/RateLimiter/tests/.phpcs.xml create mode 100644 lib/Cleantalk/Common/RateLimiter/tests/phpunit.xml create mode 100644 lib/Cleantalk/Common/RateLimiter/tests/psalm.xml create mode 100644 lib/Cleantalk/Common/Templates/.gitignore create mode 100644 lib/Cleantalk/Common/Templates/Dto.php create mode 100644 lib/Cleantalk/Common/Templates/Multiton.php create mode 100644 lib/Cleantalk/Common/Templates/Singleton.php create mode 100644 lib/Cleantalk/Common/Templates/composer.json diff --git a/composer.json b/composer.json index a195e0821..2407717c7 100644 --- a/composer.json +++ b/composer.json @@ -8,6 +8,9 @@ "email": "plugins@cleantalk.org" } ], + "require": { + "cleantalk/rate-limiter": "*" + }, "require-dev": { "vimeo/psalm": "^4.8", "phpunit/phpunit": "^8.5.52", diff --git a/lib/Cleantalk/Common/RateLimiter/.gitignore b/lib/Cleantalk/Common/RateLimiter/.gitignore new file mode 100644 index 000000000..cc341b5c2 --- /dev/null +++ b/lib/Cleantalk/Common/RateLimiter/.gitignore @@ -0,0 +1,4 @@ +.idea +/vendor/ +/composer.lock +/tests/.phpunit.result.cache diff --git a/lib/Cleantalk/Common/RateLimiter/README.md b/lib/Cleantalk/Common/RateLimiter/README.md new file mode 100644 index 000000000..5ab804db9 --- /dev/null +++ b/lib/Cleantalk/Common/RateLimiter/README.md @@ -0,0 +1 @@ +How to use this app here diff --git a/lib/Cleantalk/Common/RateLimiter/RateLimiter.php b/lib/Cleantalk/Common/RateLimiter/RateLimiter.php new file mode 100644 index 000000000..699f48ff3 --- /dev/null +++ b/lib/Cleantalk/Common/RateLimiter/RateLimiter.php @@ -0,0 +1,223 @@ +config = $config; + $this->current_ts = time(); + + $this->setIP(); + $this->setUA(); + $this->setUID(); + } + + /** + * Sets the unique identifier for the current request + * Must be implemented by child classes + * + * @return void + */ + protected function setUID(): void + { + $this->uid = md5($this->ip . $this->ua . $this->config->type); + } + + /** + * Determines if the current request should be rate limited + * + * @return bool True if request should be allowed or error occurred, false if rate limited + */ + public function checkPassed(): bool + { + try { + if (!$this->healthCheck()) { + throw new \Exception('HEALTH_CHECK_FAILED'); + } + + if (!$this->cleanUp()) { + throw new \Exception('CLEANUP_FAILED'); + } + + $uid_data = $this->selectUIDData(); + $record_found = false !== $uid_data; + + if ($record_found && !$uid_data->data_ok) { + throw new \Exception('UID_DATA_INVALID'); + } + + if ($record_found) { + if (!$this->increment($uid_data)) { + throw new \Exception('INCREMENT_FAILED'); + } + } else { + $uid_data = new RateLimiterDTO( + array( + 'uid' => $this->uid, + 'type' => $this->config->type, + 'counter' => 1, + 'last_call' => $this->current_ts, + 'created_at' => $this->current_ts, + 'ip' => $this->ip, + 'ua' => $this->ua, + ) + ); + if (!$uid_data->data_ok) { + throw new \Exception('UID_DATA_INVALID__INSERT'); + } + if (!$this->insert($uid_data)) { + throw new \Exception('INSERT_FAILED'); + } + } + } catch (\Exception $e) { + $this->process_ok = false; + $this->handleErrors($e->getMessage()); + return true; + } + + return !$this->isLocked($uid_data); + } + + /** + * Checks if the current UID has exceeded the rate limit + * + * @param RateLimiterDTO $uid_data + * @return bool True if rate limited, false otherwise + */ + protected function isLocked(RateLimiterDTO $uid_data): bool + { + return $uid_data->data_ok && ($uid_data->counter > $this->config->limit); + } + + /** + * Performs basic health check on configuration + * + * @return bool True if configuration is valid, false otherwise + */ + protected function healthCheck(): bool + { + if ($this->config->limit < 1) { + return false; + } + if ($this->config->period < 1) { + return false; + } + if ($this->config->type === null) { + return false; + } + return true; + } + + /** + * Retrieves rate limit data for the current UID + * Default implementation returns empty data + * + * @return RateLimiterDTO|false Rate limit data or false if not found + */ + protected function selectUIDData() + { + return new RateLimiterDTO(array()); + } + + /** + * Sets the IP address for the current request + * Must be implemented by child classes + * + * @return void + */ + abstract protected function setIP(): void; + + /** + * Sets the IP address for the current request + * Must be implemented by child classes + * + * @return void + */ + abstract protected function setUA(): void; + + /** + * Handles errors that occur during rate limiting + * Must be implemented by child classes + * + * @param string $msg Error message + * @return void + */ + abstract protected function handleErrors(string $msg): void; + + /** + * Increments the counter for an existing rate limit record + * Must be implemented by child classes + * @param RateLimiterDTO $uid_data + * @return bool True on success, false on failure + */ + abstract protected function increment(RateLimiterDTO $uid_data): bool; + + /** + * Inserts a new rate limit record + * Must be implemented by child classes + * @param RateLimiterDTO $uid_data + * @return bool True on success, false on failure + */ + abstract protected function insert(RateLimiterDTO $uid_data): bool; + + /** + * Cleans up expired rate limit records + * Must be implemented by child classes + * + * @return bool True on success, false on failure + */ + abstract protected function cleanUp(): bool; +} diff --git a/lib/Cleantalk/Common/RateLimiter/RateLimiterConfig.php b/lib/Cleantalk/Common/RateLimiter/RateLimiterConfig.php new file mode 100644 index 000000000..93f673b38 --- /dev/null +++ b/lib/Cleantalk/Common/RateLimiter/RateLimiterConfig.php @@ -0,0 +1,50 @@ +type = $type; + $this->limit = $limit; + $this->period = $period; + } +} diff --git a/lib/Cleantalk/Common/RateLimiter/RateLimiterDto.php b/lib/Cleantalk/Common/RateLimiter/RateLimiterDto.php new file mode 100644 index 000000000..59a83726b --- /dev/null +++ b/lib/Cleantalk/Common/RateLimiter/RateLimiterDto.php @@ -0,0 +1,94 @@ +data_ok = true; + } catch (\Exception $_e) { + $this->data_ok = false; + } + } +} diff --git a/lib/Cleantalk/Common/RateLimiter/composer.json b/lib/Cleantalk/Common/RateLimiter/composer.json new file mode 100644 index 000000000..ee5c761d3 --- /dev/null +++ b/lib/Cleantalk/Common/RateLimiter/composer.json @@ -0,0 +1,39 @@ +{ + "name": "cleantalk/rate-limiter", + "description": "CleanTalk APBCT RateLimit classes", + "authors": [ + { + "name": "CleanTalk Team", + "email": "support@cleantalk.org" + } + ], + "license": "GPL-3.0-or-later", + "autoload": { + "psr-4": { + "Cleantalk\\Common\\RateLimiter\\": "/" + } + }, + "require": { + "cleantalk/templates": "*" + }, + "require-dev": { + "vimeo/psalm": "^4.8", + "phpunit/phpunit": "^8.5.52", + "squizlabs/php_codesniffer": "3.*", + "phpcompatibility/php-compatibility": "^9.3" + }, + "config": { + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + } + }, + "scripts": { + "test": [ + "vendor/bin/phpunit --configuration tests/phpunit.xml", + "vendor/bin/phpcs --config-set installed_paths vendor/phpcompatibility/php-compatibility", + "vendor/bin/phpcs --standard=tests/.phpcs.xml", + "vendor/bin/psalm --no-cache --config=tests/psalm.xml", + "vendor/bin/psalm --no-cache --config=tests/psalm.xml --taint-analysis" + ] + } +} diff --git a/lib/Cleantalk/Common/RateLimiter/tests/.phpcs.xml b/lib/Cleantalk/Common/RateLimiter/tests/.phpcs.xml new file mode 100644 index 000000000..ca06694be --- /dev/null +++ b/lib/Cleantalk/Common/RateLimiter/tests/.phpcs.xml @@ -0,0 +1,30 @@ + + + Sniff code to check different PHP compatibility an PSR-12 Code Style + + + ../ + + + lib/* + tests/* + vendor/* + node_modules/* + + + + + + + + + + + + + + + + + + diff --git a/lib/Cleantalk/Common/RateLimiter/tests/phpunit.xml b/lib/Cleantalk/Common/RateLimiter/tests/phpunit.xml new file mode 100644 index 000000000..df01333e0 --- /dev/null +++ b/lib/Cleantalk/Common/RateLimiter/tests/phpunit.xml @@ -0,0 +1,16 @@ + + + + + ./ + ./bootstrap.php + + + diff --git a/lib/Cleantalk/Common/RateLimiter/tests/psalm.xml b/lib/Cleantalk/Common/RateLimiter/tests/psalm.xml new file mode 100644 index 000000000..a17d758c6 --- /dev/null +++ b/lib/Cleantalk/Common/RateLimiter/tests/psalm.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + diff --git a/lib/Cleantalk/Common/Templates/.gitignore b/lib/Cleantalk/Common/Templates/.gitignore new file mode 100644 index 000000000..cb424410f --- /dev/null +++ b/lib/Cleantalk/Common/Templates/.gitignore @@ -0,0 +1,3 @@ +/.idea/ +/vendor/ +/composer.lock \ No newline at end of file diff --git a/lib/Cleantalk/Common/Templates/Dto.php b/lib/Cleantalk/Common/Templates/Dto.php new file mode 100644 index 000000000..482837b2a --- /dev/null +++ b/lib/Cleantalk/Common/Templates/Dto.php @@ -0,0 +1,59 @@ +isObligatoryParamsPresented($params) ) { + throw new \Exception('No go!'); + } + + foreach ( $params as $param_name => $param ) { + if ( property_exists(static::class, $param_name) ) { + $type = gettype($this->$param_name); + $this->$param_name = $param; + settype($this->$param_name, $type); + } + } + } + + /** + * @param $params + * + * @return bool + * @since 1.1.0 + * + */ + private function isObligatoryParamsPresented($params) + { + return empty($this->obligatory_properties) || + count(array_intersect($this->obligatory_properties, array_keys($params))) === count( + $this->obligatory_properties + ); + } + + /** + * Get array of all DTO properties as key-value, except obligatory_properties. + * @return array + */ + public function getArray() + { + $array = array(); + foreach (get_object_vars($this) as $key => $value) { + $array[$key] = $value; + } + unset($array['obligatory_properties']); + return $array; + } +} diff --git a/lib/Cleantalk/Common/Templates/Multiton.php b/lib/Cleantalk/Common/Templates/Multiton.php new file mode 100644 index 000000000..7fef9b70b --- /dev/null +++ b/lib/Cleantalk/Common/Templates/Multiton.php @@ -0,0 +1,28 @@ +init($params); + } + + return static::$instances[$instance]; + } +} diff --git a/lib/Cleantalk/Common/Templates/Singleton.php b/lib/Cleantalk/Common/Templates/Singleton.php new file mode 100644 index 000000000..72bc5d8a0 --- /dev/null +++ b/lib/Cleantalk/Common/Templates/Singleton.php @@ -0,0 +1,47 @@ +init($params); + } + return static::$instance; + } + + /** + * Alternative constructor + */ + protected function init() + { + } + + public static function resetInstance() + { + static::$instance = null; + } + } +} diff --git a/lib/Cleantalk/Common/Templates/composer.json b/lib/Cleantalk/Common/Templates/composer.json new file mode 100644 index 000000000..ab47d8524 --- /dev/null +++ b/lib/Cleantalk/Common/Templates/composer.json @@ -0,0 +1,16 @@ +{ + "name": "cleantalk/templates", + "description": "CleanTalk APBCT traits", + "authors": [ + { + "name": "CleanTalk Team", + "email": "support@cleantalk.org" + } + ], + "license": "GPL-3.0-or-later", + "autoload": { + "psr-4": { + "Cleantalk\\Common\\Templates\\": "/" + } + } +} From 93ecb672322c7bd45b34d8711efb8b337a3ae9ee Mon Sep 17 00:00:00 2001 From: AntonV1211 Date: Mon, 29 Jun 2026 14:32:52 +0700 Subject: [PATCH 16/35] Fix. RemoteCalls. Editing multiple calls --- .../ApbctWP/RateLimit/ApbctRateLimiter.php | 183 +++++++++++++ lib/Cleantalk/ApbctWP/RemoteCalls.php | 33 ++- lib/Cleantalk/ApbctWP/State.php | 4 + lib/Cleantalk/Common/Schema.php | 11 + psalm.xml | 3 + .../RateLimit/TestApbctRateLimiter.php | 256 ++++++++++++++++++ tests/ApbctWP/TestRemoteCalls.php | 96 +++++++ 7 files changed, 583 insertions(+), 3 deletions(-) create mode 100644 lib/Cleantalk/ApbctWP/RateLimit/ApbctRateLimiter.php create mode 100644 tests/ApbctWP/RateLimit/TestApbctRateLimiter.php diff --git a/lib/Cleantalk/ApbctWP/RateLimit/ApbctRateLimiter.php b/lib/Cleantalk/ApbctWP/RateLimit/ApbctRateLimiter.php new file mode 100644 index 000000000..cdafca33a --- /dev/null +++ b/lib/Cleantalk/ApbctWP/RateLimit/ApbctRateLimiter.php @@ -0,0 +1,183 @@ +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); + } +} diff --git a/lib/Cleantalk/ApbctWP/RemoteCalls.php b/lib/Cleantalk/ApbctWP/RemoteCalls.php index 363e49c64..578631ec9 100644 --- a/lib/Cleantalk/ApbctWP/RemoteCalls.php +++ b/lib/Cleantalk/ApbctWP/RemoteCalls.php @@ -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 @@ -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; @@ -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'))); } @@ -126,6 +130,10 @@ public static function perform() (self::checkToken($token)) || (self::isAllowedWithoutToken($action) && self::checkWithoutToken()) ) { + // Update last_call only for authenticated 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; @@ -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. + * + * @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(); + } } diff --git a/lib/Cleantalk/ApbctWP/State.php b/lib/Cleantalk/ApbctWP/State.php index 3d3e859c0..4c2fc7e97 100644 --- a/lib/Cleantalk/ApbctWP/State.php +++ b/lib/Cleantalk/ApbctWP/State.php @@ -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); diff --git a/lib/Cleantalk/Common/Schema.php b/lib/Cleantalk/Common/Schema.php index 0151d006a..ddbcd80c8 100644 --- a/lib/Cleantalk/Common/Schema.php +++ b/lib/Cleantalk/Common/Schema.php @@ -100,6 +100,17 @@ class Schema '__indexes' => 'PRIMARY KEY (`id`)', '__createkey' => 'INT unsigned primary KEY AUTO_INCREMENT FIRST' ), + 'rate_limits' => array( + 'uid' => 'VARCHAR(32) NOT NULL', + 'type' => 'VARCHAR(64) NOT NULL', + 'ip' => 'VARCHAR(45) NOT NULL', + 'ua' => 'VARCHAR(512) NOT NULL DEFAULT \'\'' , + 'counter' => 'INT NOT NULL DEFAULT 0', + 'last_call' => 'INT NOT NULL DEFAULT 0', + 'created_at' => 'INT NOT NULL DEFAULT 0', + '__indexes' => 'PRIMARY KEY (`uid`), INDEX (`type`), INDEX (`created_at`)', + '__createkey' => 'VARCHAR(32) NOT NULL primary KEY FIRST' + ), ); /** diff --git a/psalm.xml b/psalm.xml index 64d954f0c..ba15ede07 100644 --- a/psalm.xml +++ b/psalm.xml @@ -26,6 +26,9 @@ + + + diff --git a/tests/ApbctWP/RateLimit/TestApbctRateLimiter.php b/tests/ApbctWP/RateLimit/TestApbctRateLimiter.php new file mode 100644 index 000000000..e1079a91c --- /dev/null +++ b/tests/ApbctWP/RateLimit/TestApbctRateLimiter.php @@ -0,0 +1,256 @@ +test_ip = $ip; + $this->test_ua = $ua; + parent::__construct($config); + } + + protected function setIP(): void + { + $this->ip = $this->test_ip; + } + + protected function setUA(): void + { + $this->ua = $this->test_ua; + } + + /** + * Same override as ApbctRateLimiter — per-IP only, ignoring UA. + */ + protected function setUID(): void + { + $this->uid = md5($this->ip . $this->config->type); + } + + protected function handleErrors(string $msg): void + { + } + + protected function increment(RateLimiterDto $uid_data): bool + { + return true; + } + + protected function insert(RateLimiterDto $uid_data): bool + { + return true; + } + + protected function cleanUp(): bool + { + return true; + } + + // --- Expose protected properties for assertions --- + + public function getUID(): string + { + return $this->uid; + } + + public function getIP(): string + { + return $this->ip; + } + + public function getUA(): string + { + return $this->ua; + } +} + +/** + * Stub with the DEFAULT base RateLimiter::setUID() (ip + ua + type) + * to demonstrate the difference with the per-IP override. + */ +class TestRateLimiterBaseStub extends RateLimiter +{ + private $test_ip; + private $test_ua; + + public function __construct(RateLimiterConfig $config, string $ip, string $ua) + { + $this->test_ip = $ip; + $this->test_ua = $ua; + parent::__construct($config); + } + + protected function setIP(): void + { + $this->ip = $this->test_ip; + } + + protected function setUA(): void + { + $this->ua = $this->test_ua; + } + + // No setUID override — uses base RateLimiter logic: md5(ip + ua + type) + + protected function handleErrors(string $msg): void + { + } + + protected function increment(RateLimiterDto $uid_data): bool + { + return true; + } + + protected function insert(RateLimiterDto $uid_data): bool + { + return true; + } + + protected function cleanUp(): bool + { + return true; + } + + public function getUID(): string + { + return $this->uid; + } +} + +class TestApbctRateLimiter extends TestCase +{ + /** + * @test + * UID must equal md5(ip + type), without UA component + */ + public function uidMatchesExpectedHashOfIpAndType() + { + $config = new RateLimiterConfig('rc_remote_call', 10, 60); + $limiter = new TestRateLimiterStub($config, '192.168.1.1', 'Mozilla/5.0'); + + $expected = md5('192.168.1.1' . 'rc_remote_call'); + $this->assertSame($expected, $limiter->getUID()); + } + + /** + * @test + * Same IP with different User-Agents MUST produce the same UID (per-IP blocking). + * This is the core security fix — attacker cannot bypass rate limit by rotating UA. + */ + public function sameIpDifferentUaProducesSameUid() + { + $config = new RateLimiterConfig('rc_remote_call', 10, 60); + + $limiter1 = new TestRateLimiterStub($config, '10.0.0.1', 'Mozilla/5.0'); + $limiter2 = new TestRateLimiterStub($config, '10.0.0.1', 'curl/7.68.0'); + $limiter3 = new TestRateLimiterStub($config, '10.0.0.1', ''); + + $this->assertSame($limiter1->getUID(), $limiter2->getUID()); + $this->assertSame($limiter1->getUID(), $limiter3->getUID()); + } + + /** + * @test + * Different IPs must produce different UIDs — rate limit is per-IP. + */ + public function differentIpsProduceDifferentUids() + { + $config = new RateLimiterConfig('rc_remote_call', 10, 60); + + $limiter1 = new TestRateLimiterStub($config, '10.0.0.1', 'Mozilla/5.0'); + $limiter2 = new TestRateLimiterStub($config, '10.0.0.2', 'Mozilla/5.0'); + + $this->assertNotSame($limiter1->getUID(), $limiter2->getUID()); + } + + /** + * @test + * Different config types produce different UIDs for the same IP. + */ + public function differentTypesProduceDifferentUids() + { + $config1 = new RateLimiterConfig('rc_remote_call', 10, 60); + $config2 = new RateLimiterConfig('login_attempt', 5, 300); + + $limiter1 = new TestRateLimiterStub($config1, '10.0.0.1', 'Mozilla/5.0'); + $limiter2 = new TestRateLimiterStub($config2, '10.0.0.1', 'Mozilla/5.0'); + + $this->assertNotSame($limiter1->getUID(), $limiter2->getUID()); + } + + /** + * @test + * Verify that the base RateLimiter (without override) DOES include UA in UID, + * while our override does NOT. This proves the override changes behavior. + */ + public function overrideExcludesUaWhileBaseIncludes() + { + $config = new RateLimiterConfig('rc_remote_call', 10, 60); + + // Base: same IP + different UA → different UID + $base1 = new TestRateLimiterBaseStub($config, '10.0.0.1', 'Mozilla/5.0'); + $base2 = new TestRateLimiterBaseStub($config, '10.0.0.1', 'curl/7.68.0'); + $this->assertNotSame($base1->getUID(), $base2->getUID(), 'Base UID should include UA'); + + // Override: same IP + different UA → SAME UID + $override1 = new TestRateLimiterStub($config, '10.0.0.1', 'Mozilla/5.0'); + $override2 = new TestRateLimiterStub($config, '10.0.0.1', 'curl/7.68.0'); + $this->assertSame($override1->getUID(), $override2->getUID(), 'Override UID should ignore UA'); + } + + /** + * @test + * UID is always a 32-char hex string (MD5 hash). + */ + public function uidIsValidMd5Hash() + { + $config = new RateLimiterConfig('rc_remote_call', 10, 60); + $limiter = new TestRateLimiterStub($config, '192.168.1.100', 'SomeAgent'); + + $uid = $limiter->getUID(); + $this->assertSame(32, strlen($uid)); + $this->assertRegExp('/^[0-9a-f]{32}$/', $uid); + } + + /** + * @test + * IPv6 address should also work correctly for UID generation. + */ + public function uidWorksWithIpv6() + { + $config = new RateLimiterConfig('rc_remote_call', 10, 60); + + $limiter1 = new TestRateLimiterStub($config, '::1', 'Mozilla/5.0'); + $limiter2 = new TestRateLimiterStub($config, '::1', 'curl/7.68.0'); + + $expected = md5('::1' . 'rc_remote_call'); + $this->assertSame($expected, $limiter1->getUID()); + $this->assertSame($limiter1->getUID(), $limiter2->getUID()); + } + + /** + * @test + * Empty IP edge case — should still produce a deterministic UID. + */ + public function uidWithEmptyIp() + { + $config = new RateLimiterConfig('rc_remote_call', 10, 60); + + $limiter = new TestRateLimiterStub($config, '', 'Mozilla/5.0'); + + $expected = md5('' . 'rc_remote_call'); + $this->assertSame($expected, $limiter->getUID()); + } +} diff --git a/tests/ApbctWP/TestRemoteCalls.php b/tests/ApbctWP/TestRemoteCalls.php index 5d84ede20..e42ed4912 100644 --- a/tests/ApbctWP/TestRemoteCalls.php +++ b/tests/ApbctWP/TestRemoteCalls.php @@ -2,6 +2,7 @@ use PHPUnit\Framework\TestCase; use Cleantalk\ApbctWP\RemoteCalls; +use Cleantalk\Common\RateLimiter\RateLimiterConfig; class TestRemoteCalls extends TestCase { @@ -381,4 +382,99 @@ public function itHidesNestedApiKeyWithUnderscore() $result['network_settings']['multisite__hoster_api_key'] ); } + + // ========================================================================= + // Rate limiting for remote calls + // ========================================================================= + + /** @test */ + public function rateLimitCheckMethodExists() + { + $this->assertTrue( + method_exists(RemoteCalls::class, 'rateLimitCheck'), + 'RemoteCalls must have a rateLimitCheck method' + ); + } + + /** @test */ + public function rateLimitCheckIsPrivateStatic() + { + $method = new ReflectionMethod(RemoteCalls::class, 'rateLimitCheck'); + $this->assertTrue($method->isPrivate(), 'rateLimitCheck must be private'); + $this->assertTrue($method->isStatic(), 'rateLimitCheck must be static'); + } + + /** @test */ + public function rateLimitCheckUsesCorrectConfig() + { + // Verify the config values used inside rateLimitCheck by inspecting the method body. + // The method creates RateLimiterConfig('rc_remote_call', 10, 60). + $method = new ReflectionMethod(RemoteCalls::class, 'rateLimitCheck'); + $method->setAccessible(true); + + // Read the source to confirm config values + $filename = $method->getFileName(); + $startLine = $method->getStartLine(); + $endLine = $method->getEndLine(); + $source = implode('', array_slice(file($filename), $startLine - 1, $endLine - $startLine + 1)); + + $this->assertStringContainsString("'rc_remote_call'", $source, 'Rate limit type must be rc_remote_call'); + $this->assertStringContainsString('10', $source, 'Rate limit must be set to 10 requests'); + $this->assertStringContainsString('60', $source, 'Rate limit period must be 60 seconds'); + } + + /** @test */ + public function performCallsRateLimitCheckBeforeCooldown() + { + // Verify that rateLimitCheck() is called BEFORE any cooldown logic in perform(). + // Read perform() source and check ordering. + $method = new ReflectionMethod(RemoteCalls::class, 'perform'); + $filename = $method->getFileName(); + $startLine = $method->getStartLine(); + $endLine = $method->getEndLine(); + $source = implode('', array_slice(file($filename), $startLine - 1, $endLine - $startLine + 1)); + + $rateLimitPos = strpos($source, 'rateLimitCheck'); + $cooldownPos = strpos($source, 'last_call'); + + $this->assertNotFalse($rateLimitPos, 'perform() must call rateLimitCheck'); + $this->assertNotFalse($cooldownPos, 'perform() must reference last_call'); + $this->assertLessThan($cooldownPos, $rateLimitPos, 'rateLimitCheck must be called BEFORE last_call / cooldown logic'); + } + + /** @test */ + public function lastCallIsUpdatedAfterTokenCheck() + { + // Verify that last_call is updated AFTER checkToken / isAllowedWithoutToken, + // not before. This prevents attackers from updating cooldown with bogus tokens. + $method = new ReflectionMethod(RemoteCalls::class, 'perform'); + $filename = $method->getFileName(); + $startLine = $method->getStartLine(); + $endLine = $method->getEndLine(); + $source = implode('', array_slice(file($filename), $startLine - 1, $endLine - $startLine + 1)); + + $checkTokenPos = strpos($source, 'checkToken'); + $lastCallUpdate = strpos($source, "['last_call'] = time()"); + + $this->assertNotFalse($checkTokenPos, 'perform() must call checkToken'); + $this->assertNotFalse($lastCallUpdate, 'perform() must update last_call'); + $this->assertGreaterThan( + $checkTokenPos, + $lastCallUpdate, + 'last_call update must come AFTER token validation' + ); + } + + /** @test */ + public function performDiesWithRateLimitErrorWhenBlocked() + { + // Verify the error response format for rate-limited requests + $method = new ReflectionMethod(RemoteCalls::class, 'perform'); + $filename = $method->getFileName(); + $startLine = $method->getStartLine(); + $endLine = $method->getEndLine(); + $source = implode('', array_slice(file($filename), $startLine - 1, $endLine - $startLine + 1)); + + $this->assertStringContainsString('RATE_LIMIT_EXCEEDED', $source, 'perform() must use RATE_LIMIT_EXCEEDED error code'); + } } From 9bda3410e76947b803a5854273fe56c0cb9da44e Mon Sep 17 00:00:00 2001 From: AntonV1211 Date: Tue, 30 Jun 2026 16:55:24 +0700 Subject: [PATCH 17/35] Fixs review copilot --- .../ApbctWP/RateLimit/ApbctRateLimiter.php | 5 -- lib/Cleantalk/ApbctWP/RemoteCalls.php | 5 +- .../ApbctWP/UpdatePlugin/DbColumnCreator.php | 64 +++++++++++-------- .../Common/RateLimiter/RateLimiter.php | 18 +++--- .../Common/RateLimiter/composer.json | 2 +- .../Common/RateLimiter/tests/psalm.xml | 2 +- lib/Cleantalk/Common/Schema.php | 7 +- lib/Cleantalk/Common/Templates/Singleton.php | 2 +- lib/Cleantalk/Common/Templates/composer.json | 2 +- tests/ApbctWP/TestRemoteCalls.php | 1 - 10 files changed, 55 insertions(+), 53 deletions(-) diff --git a/lib/Cleantalk/ApbctWP/RateLimit/ApbctRateLimiter.php b/lib/Cleantalk/ApbctWP/RateLimit/ApbctRateLimiter.php index cdafca33a..a5605c798 100644 --- a/lib/Cleantalk/ApbctWP/RateLimit/ApbctRateLimiter.php +++ b/lib/Cleantalk/ApbctWP/RateLimit/ApbctRateLimiter.php @@ -3,7 +3,6 @@ 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; @@ -103,8 +102,6 @@ protected function selectUIDData() * * @param RateLimiterDto $uid_data * @return bool - * - * @psalm-suppress MethodSignatureMismatch - library uses incorrect casing RateLimiterDTO vs RateLimiterDto */ protected function insert(RateLimiterDto $uid_data): bool { @@ -134,8 +131,6 @@ protected function insert(RateLimiterDto $uid_data): bool * * @param RateLimiterDto $uid_data * @return bool - * - * @psalm-suppress MethodSignatureMismatch - library uses incorrect casing RateLimiterDTO vs RateLimiterDto */ protected function increment(RateLimiterDto $uid_data): bool { diff --git a/lib/Cleantalk/ApbctWP/RemoteCalls.php b/lib/Cleantalk/ApbctWP/RemoteCalls.php index 578631ec9..3821d9e9f 100644 --- a/lib/Cleantalk/ApbctWP/RemoteCalls.php +++ b/lib/Cleantalk/ApbctWP/RemoteCalls.php @@ -4,7 +4,6 @@ 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; @@ -130,7 +129,7 @@ public static function perform() (self::checkToken($token)) || (self::isAllowedWithoutToken($action) && self::checkWithoutToken()) ) { - // Update last_call only for authenticated requests + // Update last_call only for authorized requests $apbct->remote_calls[$action]['last_call'] = time(); $apbct->save('remote_calls'); @@ -688,7 +687,7 @@ private static function hideSensitiveData($data) /** * Rate limit check for remote calls. - * Blocks abusive IPs/UAs that exceed 10 requests per 60 seconds. + * Blocks abusive IPs that exceed 10 requests per 60 seconds. * * @return bool True if request is allowed, false if rate limited */ diff --git a/lib/Cleantalk/ApbctWP/UpdatePlugin/DbColumnCreator.php b/lib/Cleantalk/ApbctWP/UpdatePlugin/DbColumnCreator.php index 46290174f..3ae812be2 100644 --- a/lib/Cleantalk/ApbctWP/UpdatePlugin/DbColumnCreator.php +++ b/lib/Cleantalk/ApbctWP/UpdatePlugin/DbColumnCreator.php @@ -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; } } } @@ -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"; @@ -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"; diff --git a/lib/Cleantalk/Common/RateLimiter/RateLimiter.php b/lib/Cleantalk/Common/RateLimiter/RateLimiter.php index 699f48ff3..d1da08791 100644 --- a/lib/Cleantalk/Common/RateLimiter/RateLimiter.php +++ b/lib/Cleantalk/Common/RateLimiter/RateLimiter.php @@ -104,7 +104,7 @@ public function checkPassed(): bool throw new \Exception('INCREMENT_FAILED'); } } else { - $uid_data = new RateLimiterDTO( + $uid_data = new RateLimiterDto( array( 'uid' => $this->uid, 'type' => $this->config->type, @@ -134,10 +134,10 @@ public function checkPassed(): bool /** * Checks if the current UID has exceeded the rate limit * - * @param RateLimiterDTO $uid_data + * @param RateLimiterDto $uid_data * @return bool True if rate limited, false otherwise */ - protected function isLocked(RateLimiterDTO $uid_data): bool + protected function isLocked(RateLimiterDto $uid_data): bool { return $uid_data->data_ok && ($uid_data->counter > $this->config->limit); } @@ -165,11 +165,11 @@ protected function healthCheck(): bool * Retrieves rate limit data for the current UID * Default implementation returns empty data * - * @return RateLimiterDTO|false Rate limit data or false if not found + * @return RateLimiterDto|false Rate limit data or false if not found */ protected function selectUIDData() { - return new RateLimiterDTO(array()); + return new RateLimiterDto(array()); } /** @@ -200,18 +200,18 @@ abstract protected function handleErrors(string $msg): void; /** * Increments the counter for an existing rate limit record * Must be implemented by child classes - * @param RateLimiterDTO $uid_data + * @param RateLimiterDto $uid_data * @return bool True on success, false on failure */ - abstract protected function increment(RateLimiterDTO $uid_data): bool; + abstract protected function increment(RateLimiterDto $uid_data): bool; /** * Inserts a new rate limit record * Must be implemented by child classes - * @param RateLimiterDTO $uid_data + * @param RateLimiterDto $uid_data * @return bool True on success, false on failure */ - abstract protected function insert(RateLimiterDTO $uid_data): bool; + abstract protected function insert(RateLimiterDto $uid_data): bool; /** * Cleans up expired rate limit records diff --git a/lib/Cleantalk/Common/RateLimiter/composer.json b/lib/Cleantalk/Common/RateLimiter/composer.json index ee5c761d3..422de8331 100644 --- a/lib/Cleantalk/Common/RateLimiter/composer.json +++ b/lib/Cleantalk/Common/RateLimiter/composer.json @@ -10,7 +10,7 @@ "license": "GPL-3.0-or-later", "autoload": { "psr-4": { - "Cleantalk\\Common\\RateLimiter\\": "/" + "Cleantalk\\Common\\RateLimiter\\": "./" } }, "require": { diff --git a/lib/Cleantalk/Common/RateLimiter/tests/psalm.xml b/lib/Cleantalk/Common/RateLimiter/tests/psalm.xml index a17d758c6..28a40a3e9 100644 --- a/lib/Cleantalk/Common/RateLimiter/tests/psalm.xml +++ b/lib/Cleantalk/Common/RateLimiter/tests/psalm.xml @@ -11,7 +11,7 @@ allowNamedArgumentCalls="false" hoistConstants="true" runTaintAnalysis="true" - phpVersion="5.6" + phpVersion="7.2" hideExternalErrors="true" xsi:schemaLocation="https://getpsalm.org/schema/config config.xsd" limitMethodComplexity="true" diff --git a/lib/Cleantalk/Common/Schema.php b/lib/Cleantalk/Common/Schema.php index ddbcd80c8..0d2788b7a 100644 --- a/lib/Cleantalk/Common/Schema.php +++ b/lib/Cleantalk/Common/Schema.php @@ -101,15 +101,16 @@ class Schema '__createkey' => 'INT unsigned primary KEY AUTO_INCREMENT FIRST' ), 'rate_limits' => array( + 'id' => 'INT NOT NULL AUTO_INCREMENT', 'uid' => 'VARCHAR(32) NOT NULL', 'type' => 'VARCHAR(64) NOT NULL', 'ip' => 'VARCHAR(45) NOT NULL', - 'ua' => 'VARCHAR(512) NOT NULL DEFAULT \'\'' , + 'ua' => 'VARCHAR(512) NOT NULL DEFAULT \'\'', 'counter' => 'INT NOT NULL DEFAULT 0', 'last_call' => 'INT NOT NULL DEFAULT 0', 'created_at' => 'INT NOT NULL DEFAULT 0', - '__indexes' => 'PRIMARY KEY (`uid`), INDEX (`type`), INDEX (`created_at`)', - '__createkey' => 'VARCHAR(32) NOT NULL primary KEY FIRST' + '__indexes' => 'PRIMARY KEY (`id`), UNIQUE KEY `uid` (`uid`), INDEX (`type`), INDEX (`created_at`)', + '__createkey' => 'INT unsigned primary KEY AUTO_INCREMENT FIRST' ), ); diff --git a/lib/Cleantalk/Common/Templates/Singleton.php b/lib/Cleantalk/Common/Templates/Singleton.php index 72bc5d8a0..e21581a72 100644 --- a/lib/Cleantalk/Common/Templates/Singleton.php +++ b/lib/Cleantalk/Common/Templates/Singleton.php @@ -35,7 +35,7 @@ public static function getInstance($params = array()) /** * Alternative constructor */ - protected function init() + protected function init($params = array()) { } diff --git a/lib/Cleantalk/Common/Templates/composer.json b/lib/Cleantalk/Common/Templates/composer.json index ab47d8524..5f406249e 100644 --- a/lib/Cleantalk/Common/Templates/composer.json +++ b/lib/Cleantalk/Common/Templates/composer.json @@ -10,7 +10,7 @@ "license": "GPL-3.0-or-later", "autoload": { "psr-4": { - "Cleantalk\\Common\\Templates\\": "/" + "Cleantalk\\Common\\Templates\\": "./" } } } diff --git a/tests/ApbctWP/TestRemoteCalls.php b/tests/ApbctWP/TestRemoteCalls.php index e42ed4912..60873d4ca 100644 --- a/tests/ApbctWP/TestRemoteCalls.php +++ b/tests/ApbctWP/TestRemoteCalls.php @@ -2,7 +2,6 @@ use PHPUnit\Framework\TestCase; use Cleantalk\ApbctWP\RemoteCalls; -use Cleantalk\Common\RateLimiter\RateLimiterConfig; class TestRemoteCalls extends TestCase { From c9bb86255e66b4eee7efcfaf35dc2dfec0b034d7 Mon Sep 17 00:00:00 2001 From: AntonV1211 Date: Tue, 30 Jun 2026 18:16:19 +0700 Subject: [PATCH 18/35] Upd rate limit common lib --- lib/Cleantalk/Common/RateLimiter/RateLimiter.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/Cleantalk/Common/RateLimiter/RateLimiter.php b/lib/Cleantalk/Common/RateLimiter/RateLimiter.php index d1da08791..7f731f246 100644 --- a/lib/Cleantalk/Common/RateLimiter/RateLimiter.php +++ b/lib/Cleantalk/Common/RateLimiter/RateLimiter.php @@ -99,6 +99,10 @@ public function checkPassed(): bool throw new \Exception('UID_DATA_INVALID'); } + if ($record_found && $this->isLocked($uid_data)) { + return false; // Block here by limit exceeded + } + if ($record_found) { if (!$this->increment($uid_data)) { throw new \Exception('INCREMENT_FAILED'); @@ -128,7 +132,7 @@ public function checkPassed(): bool return true; } - return !$this->isLocked($uid_data); + return true; } /** From 625bf792f6b15fc9080ca2e2844c3787cfd56837 Mon Sep 17 00:00:00 2001 From: SVFCode Date: Wed, 1 Jul 2026 12:05:19 +0500 Subject: [PATCH 19/35] Upd. Settings. Implement honey test. (#823) * Upd. Settings. Implement honey test. * fix edge case --- .../IntegrationsByClass/Woocommerce.php | 22 +- lib/Cleantalk/ApbctWP/WcFieldSwapHoneypot.php | 303 ++++++++++++++++++ tests/ApbctWP/TestWcFieldSwapHoneypot.php | 179 +++++++++++ 3 files changed, 502 insertions(+), 2 deletions(-) create mode 100644 lib/Cleantalk/ApbctWP/WcFieldSwapHoneypot.php create mode 100644 tests/ApbctWP/TestWcFieldSwapHoneypot.php diff --git a/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php b/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php index 59deea4b9..e6e102d84 100644 --- a/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php +++ b/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php @@ -8,6 +8,7 @@ use Cleantalk\ApbctWP\Variables\Cookie; use Cleantalk\ApbctWP\Variables\Get; use Cleantalk\ApbctWP\Variables\Post; +use Cleantalk\ApbctWP\WcFieldSwapHoneypot; use Cleantalk\ApbctWP\WcSpamOrdersFunctions; use Cleantalk\ApbctWP\Variables\Request; use Cleantalk\ApbctWP\Variables\Server; @@ -46,6 +47,7 @@ public function doPublicWork() // honeypot add_filter('woocommerce_checkout_fields', [$this, 'addHoneypotField']); + WcFieldSwapHoneypot::registerHooks(); // add to cart hooks if cart works with non-ajax requests $this->addCartActions(); @@ -174,7 +176,10 @@ public function checkoutCheck($_data, $errors) { global $apbct, $cleantalk_executed; - if ( count($errors->errors) ) { + $swap_hp = WcFieldSwapHoneypot::getCheckoutResult(); + $honeypot_triggered = $swap_hp['status'] === 0; + + if ( count($errors->errors) && ! $honeypot_triggered ) { return; } @@ -188,6 +193,7 @@ public function checkoutCheck($_data, $errors) * Filter for POST */ $input_array = apply_filters('apbct__filter_post', $_POST); + $input_array = WcFieldSwapHoneypot::enrichInputArrayForCleanTalk($input_array); //Getting request params $ct_temp_msg_data = ct_get_fields_any($input_array); @@ -198,6 +204,10 @@ public function checkoutCheck($_data, $errors) $subject = isset($ct_temp_msg_data['subject']) ? $ct_temp_msg_data['subject'] : ''; $message = isset($ct_temp_msg_data['message']) ? $ct_temp_msg_data['message'] : array(); + if ( $honeypot_triggered && $sender_email === '' && ! empty($swap_hp['value']) ) { + $sender_email = $swap_hp['value']; + } + if ( $subject != '' ) { $message = array_merge(array('subject' => $subject), $message); } @@ -206,12 +216,20 @@ public function checkoutCheck($_data, $errors) $post_info['comment_type'] = 'order'; $post_info['post_url'] = Server::get('HTTP_REFERER'); + $sender_info = array( + 'sender_url' => null, + 'sender_emails_array' => $sender_emails_array, + 'wc_field_swap_honeypot' => $swap_hp['status'], + 'wc_field_swap_honeypot_value' => $swap_hp['value'], + 'wc_field_swap_honeypot_source' => $swap_hp['source'], + ); + $base_call_data = array( 'message' => $message, 'sender_email' => $sender_email, 'sender_nickname' => $sender_nickname, 'post_info' => $post_info, - 'sender_info' => array('sender_url' => null, 'sender_emails_array' => $sender_emails_array) + 'sender_info' => $sender_info, ); $base_call_result = apbct_base_call($base_call_data); diff --git a/lib/Cleantalk/ApbctWP/WcFieldSwapHoneypot.php b/lib/Cleantalk/ApbctWP/WcFieldSwapHoneypot.php new file mode 100644 index 000000000..c902d90b6 --- /dev/null +++ b/lib/Cleantalk/ApbctWP/WcFieldSwapHoneypot.php @@ -0,0 +1,303 @@ +settings['data__honeypot_field']) + && ! apbct_exclusions_check__url() + && ! apbct_is_amp_request(); + } + + /** + * @param array $fields + * @return array + * @psalm-suppress PossiblyUnusedReturnValue + */ + public static function swapCheckoutFields($fields) + { + if ( ! self::isActive() || self::isCheckoutBlocksPage() ) { + return $fields; + } + + if ( empty($fields['billing'][self::FIELD_BILLING_EMAIL]) ) { + return $fields; + } + + $secret = self::getSecretName(self::FIELD_BILLING_EMAIL); + + $fields['billing'][$secret] = $fields['billing'][self::FIELD_BILLING_EMAIL]; + $fields['billing'][$secret]['id'] = $secret; + unset($fields['billing'][self::FIELD_BILLING_EMAIL]); + + return $fields; + } + + public static function renderTrapAndScript() + { + if ( ! self::isActive() || self::isCheckoutBlocksPage() ) { + return; + } + + echo self::renderTrapField(self::FIELD_BILLING_EMAIL, 'email'); + echo self::renderIdSwapScript(self::FIELD_BILLING_EMAIL); + } + + /** + * @param string $canonical_name + * @param string $input_type + * @return string + */ + public static function renderTrapField($canonical_name, $input_type) + { + $trap_style = 'padding:0 !important;clip:rect(1px, 1px, 1px, 1px) !important;' + . 'position:absolute !important;white-space:nowrap !important;' + . 'height:1px !important;width:1px !important;overflow:hidden !important;'; + + return sprintf( + '', + esc_attr($input_type), + esc_attr($canonical_name), + esc_attr($canonical_name), + esc_attr($trap_style) + ); + } + + /** + * @param string $field_key + * @return string + */ + public static function renderIdSwapScript($field_key) + { + $secret = self::getSecretName($field_key); + $random_id = 'a' . substr(md5((string) time()), 0, 31); + + return ''; + } + + /** + * Stores honeypot status without changing posted data keys — WC validates by secret field name. + * + * @param array $data + * @return array + * @psalm-suppress PossiblyUnusedReturnValue + */ + public static function captureCheckoutResult($data) + { + if ( ! self::isActive() || ! self::isCheckoutSubmitRequest() ) { + return $data; + } + + self::$checkout_result = self::evaluateFromPostedData(self::getRawPostedData()); + + return $data; + } + + /** + * Adds billing_email for CleanTalk field parsing; does not alter keys used by WooCommerce. + * + * @param array $input + * @return array + */ + public static function enrichInputArrayForCleanTalk(array $input) + { + if ( ! self::isActive() || ! self::isCheckoutSubmitRequest() ) { + return $input; + } + + $secret = self::getSecretName(self::FIELD_BILLING_EMAIL); + + if ( empty($input[$secret]) ) { + return $input; + } + + if ( empty($input[self::FIELD_BILLING_EMAIL]) ) { + $input[self::FIELD_BILLING_EMAIL] = $input[$secret]; + } + + return $input; + } + + /** + * @return array{status: int|null, value: string|null, source: string|null} + */ + public static function getCheckoutResult() + { + if ( self::$checkout_result !== null ) { + return self::$checkout_result; + } + + if ( ! self::isActive() || ! self::isCheckoutSubmitRequest() ) { + return self::emptyResult(); + } + + self::$checkout_result = self::evaluateFromPostedData(self::getRawPostedData()); + + return self::$checkout_result; + } + + /** + * Raw POST including trap field (not present in WC checkout posted_data). + * + * @return array + */ + private static function getRawPostedData() + { + if ( empty($_POST) || ! is_array($_POST) ) { + return array(); + } + + $post = wp_unslash($_POST); + + return is_array($post) ? $post : array(); + } + + /** + * @param array $data + * @return array{status: int|null, value: string|null, source: string|null} + */ + public static function evaluateFromPostedData(array $data) + { + $field_key = self::FIELD_BILLING_EMAIL; + $secret = self::getSecretName($field_key); + + $trap_present = array_key_exists($field_key, $data); + $secret_present = array_key_exists($secret, $data); + + if ( ! $trap_present && ! $secret_present ) { + return self::emptyResult(); + } + + $trap_value = ''; + if ( $trap_present && isset($data[$field_key]) ) { + $trap_value = trim((string) $data[$field_key]); + } + + if ( $trap_value !== '' ) { + return array( + 'status' => 0, + 'value' => $trap_value, + 'source' => $field_key, + ); + } + + return array( + 'status' => 1, + 'value' => null, + 'source' => $field_key, + ); + } + + /** + * @param string $field_key + * @return string + */ + public static function getSecretName($field_key) + { + $salt = defined('NONCE_SALT') ? NONCE_SALT : ABSPATH; + $secret = substr(sha1(md5('apbct-wc-' . $field_key . $salt)), 0, 10); + $secret = self::ensureSecretStartsWithLetter($secret); + + return $secret; + } + + /** + * @param string $secret + * @return string + */ + private static function ensureSecretStartsWithLetter($secret) + { + $first_char = substr($secret, 0, 1); + + if ( is_numeric($first_char) ) { + return chr((int) $first_char + 97) . substr($secret, 1); + } + + return $secret; + } + + /** + * @return array{status: int|null, value: string|null, source: string|null} + */ + private static function emptyResult() + { + return array( + 'status' => null, + 'value' => null, + 'source' => null, + ); + } + + private static function isCheckoutSubmitRequest() + { + if ( apbct_is_rest() ) { + return false; + } + + if ( Request::get('wc-ajax') === 'checkout' ) { + return true; + } + + if ( empty($_POST) ) { + return false; + } + + $secret = self::getSecretName(self::FIELD_BILLING_EMAIL); + + return isset($_POST['woocommerce_checkout_place_order']) + || isset($_POST[self::FIELD_BILLING_EMAIL]) + || isset($_POST[$secret]); + } + + private static function isCheckoutBlocksPage() + { + if ( ! function_exists('is_checkout') || ! is_checkout() ) { + return false; + } + + global $post; + + if ( ! $post || ! function_exists('has_block') ) { + return false; + } + + return has_block('woocommerce/checkout', $post); + } +} diff --git a/tests/ApbctWP/TestWcFieldSwapHoneypot.php b/tests/ApbctWP/TestWcFieldSwapHoneypot.php new file mode 100644 index 000000000..53a3ffaa5 --- /dev/null +++ b/tests/ApbctWP/TestWcFieldSwapHoneypot.php @@ -0,0 +1,179 @@ +assertSame(10, strlen($secret)); + $this->assertSame($secret, WcFieldSwapHoneypot::getSecretName('billing_email')); + $this->assertFalse(is_numeric($secret[0])); + } + + public function testEvaluateFromPostedDataReturnsNullWhenSwapFieldsMissing() + { + $result = WcFieldSwapHoneypot::evaluateFromPostedData(array()); + + $this->assertNull($result['status']); + $this->assertNull($result['value']); + $this->assertNull($result['source']); + } + + public function testEvaluateFromPostedDataDetectsFilledTrap() + { + $result = WcFieldSwapHoneypot::evaluateFromPostedData(array( + 'billing_email' => 'bot@spam.test', + )); + + $this->assertSame(0, $result['status']); + $this->assertSame('bot@spam.test', $result['value']); + $this->assertSame('billing_email', $result['source']); + } + + public function testEvaluateFromPostedDataDetectsCleanTrap() + { + $secret = WcFieldSwapHoneypot::getSecretName('billing_email'); + + $result = WcFieldSwapHoneypot::evaluateFromPostedData(array( + 'billing_email' => '', + $secret => 'user@example.com', + )); + + $this->assertSame(1, $result['status']); + $this->assertNull($result['value']); + $this->assertSame('billing_email', $result['source']); + } + + public function testCaptureCheckoutResultDetectsTrapFromRawPost() + { + global $apbct; + $apbct = (object) array( + 'settings' => array( + 'data__honeypot_field' => 1, + ), + ); + + $secret = WcFieldSwapHoneypot::getSecretName('billing_email'); + $_POST = array( + 'wc-ajax' => 'checkout', + 'billing_email' => 'bot@spam.test', + $secret => '', + ); + + $wc_posted_data = array( + $secret => '', + ); + + WcFieldSwapHoneypot::captureCheckoutResult($wc_posted_data); + $result = WcFieldSwapHoneypot::getCheckoutResult(); + + $this->assertSame(0, $result['status']); + $this->assertSame('bot@spam.test', $result['value']); + $this->assertSame('billing_email', $result['source']); + + unset($_POST); + unset($apbct); + } + + public function testCaptureCheckoutResultDoesNotChangePostedDataKeys() + { + global $apbct; + $apbct = (object) array( + 'settings' => array( + 'data__honeypot_field' => 1, + ), + ); + + $secret = WcFieldSwapHoneypot::getSecretName('billing_email'); + $_POST = array( + 'wc-ajax' => 'checkout', + 'billing_email' => '', + $secret => 'user@example.com', + ); + + $posted_data = array( + 'billing_email' => '', + $secret => 'user@example.com', + ); + + $captured = WcFieldSwapHoneypot::captureCheckoutResult($posted_data); + + $this->assertSame('user@example.com', $captured[$secret]); + $this->assertArrayHasKey($secret, $captured); + $this->assertSame(1, WcFieldSwapHoneypot::getCheckoutResult()['status']); + + unset($_POST); + unset($apbct); + } + + public function testEnrichInputArrayForCleanTalkAddsBillingEmail() + { + global $apbct; + $apbct = (object) array( + 'settings' => array( + 'data__honeypot_field' => 1, + ), + ); + + $secret = WcFieldSwapHoneypot::getSecretName('billing_email'); + $_POST = array( + 'wc-ajax' => 'checkout', + 'billing_email' => '', + $secret => 'user@example.com', + ); + + $enriched = WcFieldSwapHoneypot::enrichInputArrayForCleanTalk(array( + 'billing_email' => '', + $secret => 'user@example.com', + )); + + $this->assertSame('user@example.com', $enriched['billing_email']); + $this->assertSame('user@example.com', $enriched[$secret]); + + unset($_POST); + unset($apbct); + } + + public function testSwapCheckoutFieldsRenamesBillingEmail() + { + global $apbct; + $apbct = (object) array( + 'settings' => array( + 'data__honeypot_field' => 1, + ), + ); + + $fields = array( + 'billing' => array( + 'billing_email' => array( + 'type' => 'email', + 'label' => 'Email', + 'required' => true, + ), + ), + ); + + $swapped = WcFieldSwapHoneypot::swapCheckoutFields($fields); + $secret = WcFieldSwapHoneypot::getSecretName('billing_email'); + + $this->assertArrayNotHasKey('billing_email', $swapped['billing']); + $this->assertArrayHasKey($secret, $swapped['billing']); + $this->assertSame($secret, $swapped['billing'][$secret]['id']); + + unset($apbct); + } +} From b339b632577bb137139bf1d718f32ba9dce11423 Mon Sep 17 00:00:00 2001 From: AntonV1211 Date: Wed, 1 Jul 2026 17:04:25 +0700 Subject: [PATCH 20/35] Mod. LinkConstructor. Translation of all links on the settings page to LinkConstructor --- inc/cleantalk-settings.php | 49 +- lib/Cleantalk/ApbctWP/LinkConstructor.php | 110 +++ .../TestSettingsLongDescriptionLinks.php | 643 ++++++++++++++++++ 3 files changed, 768 insertions(+), 34 deletions(-) create mode 100644 tests/ApbctWP/TestSettingsLongDescriptionLinks.php diff --git a/inc/cleantalk-settings.php b/inc/cleantalk-settings.php index 97a638250..fd19985c2 100644 --- a/inc/cleantalk-settings.php +++ b/inc/cleantalk-settings.php @@ -112,8 +112,7 @@ function apbct_settings__set_fields() 'cleantalk-spam-protect' ) . ' ' - //HANDLE LINK - . '' + . '' . esc_html__( 'contact to our support.', 'cleantalk-spam-protect' @@ -288,7 +287,7 @@ function apbct_settings__set_fields() 'Read more about %sspam protection for Search form%s on our blog. The "noindex" tag will be placed in the meta directive on the search results page.', 'cleantalk-spam-protect' ), - '', + '', '' ) : '' @@ -526,9 +525,7 @@ function apbct_settings__set_fields() . ( ! $apbct->white_label && ! $apbct->data["wl_mode_enabled"] ? __(' Or you don`t have records about missed spam here:', 'cleantalk-spam-protect') . ' ' - //HANDLE LINK - . '' + . '' . __('CleanTalk Dashboard', 'cleantalk-spam-protect') . '.' : '' ) . '
' @@ -665,7 +662,7 @@ function apbct_settings__set_fields() ), 'data__email_check_exist_post' => array( 'title' => __('Show email existence alert when filling in the field', 'cleantalk-spam-protect'), - 'description' => __('Checks the email address and shows the result as an icon in the email field before submitting the form. Works for WooCommerce checkout form, FluentForms, Contact Form 7, Gravity Forms, Ninja Forms, WPForms, standard WordPress comment form and registration form.', 'cleantalk-spam-protect') . ' ' . '' . __('Read more', 'cleantalk-spam-protect') . '', + 'description' => __('Checks the email address and shows the result as an icon in the email field before submitting the form. Works for WooCommerce checkout form, FluentForms, Contact Form 7, Gravity Forms, Ninja Forms, WPForms, standard WordPress comment form and registration form.', 'cleantalk-spam-protect') . ' ' . '' . __('Read more', 'cleantalk-spam-protect') . '', ), ), ), @@ -1125,8 +1122,7 @@ function apbct_settings__set_fields__network($fields) 'title' => __('Hoster Access key', 'cleantalk-spam-protect'), 'description' => sprintf( __('Copy the Access key from your %sCleanTalk Profile%s', 'cleantalk-spam-protect'), - //HANDLE LINK - '', + '', '' ), 'class' => 'apbct_settings-field_wrapper--sub', @@ -1370,8 +1366,7 @@ function apbct_settings__display() if ( $apbct->key_is_ok && apbct_api_key__is_correct() && ! $apbct->data["wl_mode_enabled"] ) { if ( $apbct->network_settings['multisite__work_mode'] != 2 || is_main_site() ) { // CP button - //HANDLE LINK - echo '' + echo '' . __('Cloud Dashboard', 'cleantalk-spam-protect') . '' . ''; @@ -1874,9 +1869,8 @@ function apbct_settings__field__state() 'cleantalk-spam-protect' ); if ( ! $apbct->white_label || is_main_site() ) { - //HANDLE LINK echo '' - . '' . __( + . '' . __( 'Validate email for existence', 'cleantalk-spam-protect' ) . ''; @@ -2010,7 +2004,7 @@ function apbct_settings__field__apikey() $replaces['public_offer_link'] = sprintf( '' ); @@ -3046,18 +3040,16 @@ function apbct_settings__get__long_description() ), 'data__set_cookies' => array( 'title' => __('Cookies setting', 'cleantalk-spam-protect'), - //HANDLE LINK 'desc' => sprintf( __('It determines what methods of using the HTTP cookies the Anti-Spam plugin for WordPress should switch to. It is necessary for the plugin to work properly. All CleanTalk cookies contain technical data. Data of the current website visitor is encrypted with the MD5 algorithm and being deleted when the browser session ends. %s', 'cleantalk-spam-protect'), - '' . __('Learn more about suboptions', 'cleantalk-spam-protect') . '' + '' . __('Learn more about suboptions', 'cleantalk-spam-protect') . '' ) ), 'comments__hide_website_field' => array( 'title' => __('Hide the "Website" field', 'cleantalk-spam-protect'), - //HANDLE LINK 'desc' => sprintf( __('This «Website» field is frequently used by spammers to place spam links in it. ' . esc_html__($apbct->data['wl_brandname']) . ' helps you protect your WordPress website comments by hiding this field off. %s', 'cleantalk-spam-protect'), - '' . __('Learn more.', 'cleantalk-spam-protect') . '' + '' . __('Learn more.', 'cleantalk-spam-protect') . '' ) ), 'comments__the_real_person' => array( @@ -3071,50 +3063,45 @@ function apbct_settings__get__long_description() ), 'sfw__anti_crawler' => array( 'title' => 'Anti-Crawler', // Do not to localize this phrase - //HANDLE LINK 'desc' => sprintf( '

' . sprintf( __('%1$s Anti-Crawler — this option is meant to block all types of bots visiting website pages that can search vulnerabilities on a website, attempt to hack a site, collect personal data, price parsing or content and images, generate 404 error pages, or aggressive website scanning bots. %2$s', 'cleantalk-spam-protect'), esc_html($apbct->data['wl_brandname']), - '' . __('Learn more.', 'cleantalk-spam-protect') . '' + '' . __('Learn more.', 'cleantalk-spam-protect') . '' ) . '

' . '

' . __('By default, well-known good bots are allowed, including AI crawlers like GPTBot (ChatGPT), ClaudeBot (Claude), Google-Extended (Gemini), and Copilot. You can selectively block any of these bots in your private lists.', 'cleantalk-spam-protect') . '

' ) ), 'sfw__anti_flood' => array( 'title' => 'Anti-Flood', // Do not to localize this phrase - //HANDLE LINK 'desc' => sprintf( __(esc_html__($apbct->data['wl_brandname']) . ' Anti-Flood — this option is meant to block aggressive bots. You can set the maximum number of website pages your visitors can click on within 1 minute. If any IP exceeds the set number it will get the ' . $apbct->data['wl_brandname'] . ' blocking screen for 30 seconds. It\'s impossible for the IP to open any website pages while the 30-second timer takes place. %s', 'cleantalk-spam-protect'), - '' . __('Learn more.', 'cleantalk-spam-protect') . '' + '' . __('Learn more.', 'cleantalk-spam-protect') . '' ) ), 'data__pixel' => array( 'title' => __(esc_html__($apbct->data['wl_brandname']) . ' Pixel', 'cleantalk-spam-protect'), - //HANDLE LINK 'desc' => sprintf( __('It is an «invisible» 1×1px image that the Anti-Spam plugin integrates to your WordPress website. And when someone visits your website the Pixel is triggered and reports this visit and some other data including true IP address. %s', 'cleantalk-spam-protect'), - '' . __('Learn more.', 'cleantalk-spam-protect') . '' + '' . __('Learn more.', 'cleantalk-spam-protect') . '' ) ), 'data__honeypot_field' => array( 'title' => __('Honeypot field', 'cleantalk-spam-protect'), - //HANDLE LINK 'desc' => sprintf( esc_html__('The option helps to block bots . The honeypot field option adds a hidden field to the form. When spambots come to a website form, they can fill out each input field. Enable this option to make the protection stronger on these forms. Learn more about supported forms %s', 'cleantalk-spam-protect'), - '' . __('here.', 'cleantalk-spam-protect') . '' + '' . __('here.', 'cleantalk-spam-protect') . '' ) ), 'sfw__enabled' => array( 'title' => 'SpamFireWall', - //HANDLE LINK 'desc' => sprintf( '

' . esc_html__('SpamFireWall is a part of the anti-spam service and blocks the most spam active bots before the site pages load.', 'cleantalk-spam-protect') . '

' . '

' . esc_html__('Anti-Crawler is an add-on to SFW and helps to strengthen the protection against spam bots. Disabled by default.', 'cleantalk-spam-protect') . '

' . '

' . esc_html__($apbct->data['wl_brandname'] . ' Anti-Flood is also an add-on to SFW and limits the number of pages visited per minute. Disabled by default.', 'cleantalk-spam-protect') . '

' . '

' . esc_html__('You can read more about SFW modes %s', 'cleantalk-spam-protect') . '

' . '

' . esc_html__('Read out the article if you are using Varnish on your server.', 'cleantalk-spam-protect'), - '' . __('here.', 'cleantalk-spam-protect') . '' + '' . __('here.', 'cleantalk-spam-protect') . '' ) ), 'exclusions__form_signs' => array( @@ -3189,12 +3176,6 @@ function apbct_settings__get__long_description() ); if (!empty($setting_id) && isset($descriptions[$setting_id])) { - if ($setting_id === 'comments__hide_website_field') { - $utm = '?utm_source=apbct_hint_' . esc_attr($setting_id) . '&utm_medium=hide_website_field_hint&utm_campaign=apbct_links'; - } else { - $utm = '?utm_source=apbct_hint_' . esc_attr($setting_id) . '&utm_medium=WordPress&utm_campaign=ABPCT_Settings'; - } - $descriptions[$setting_id]['desc'] = str_replace('{utm_mark}', $utm, $descriptions[$setting_id]['desc']); die(json_encode($descriptions[$setting_id])); } else { die(json_encode(['error' => 'Invalid setting ID'])); diff --git a/lib/Cleantalk/ApbctWP/LinkConstructor.php b/lib/Cleantalk/ApbctWP/LinkConstructor.php index 7f4bde96a..aa87d8168 100644 --- a/lib/Cleantalk/ApbctWP/LinkConstructor.php +++ b/lib/Cleantalk/ApbctWP/LinkConstructor.php @@ -230,6 +230,116 @@ class LinkConstructor extends \Cleantalk\Common\LinkConstructor 'utm_content' => 'avatar', 'utm_campaign' => 'apbct_links', ), + /* + * Settings long descriptions (hints) + */ + 'apbct_hint_data__set_cookies' => array( + 'utm_id' => '', + 'utm_term' => '', + 'utm_source' => 'apbct_hint_data__set_cookies', + 'utm_medium' => 'WordPress', + 'utm_campaign' => 'ABPCT_Settings', + ), + 'apbct_hint_comments__hide_website_field' => array( + 'utm_id' => '', + 'utm_term' => '', + 'utm_source' => 'apbct_hint_comments__hide_website_field', + 'utm_medium' => 'hide_website_field_hint', + ), + 'apbct_hint_sfw__anti_crawler' => array( + 'utm_id' => '', + 'utm_term' => '', + 'utm_source' => 'apbct_hint_sfw__anti_crawler', + 'utm_medium' => 'WordPress', + 'utm_campaign' => 'ABPCT_Settings', + ), + 'apbct_hint_sfw__anti_flood' => array( + 'utm_id' => '', + 'utm_term' => '', + 'utm_source' => 'apbct_hint_sfw__anti_flood', + 'utm_medium' => 'WordPress', + 'utm_campaign' => 'ABPCT_Settings', + ), + 'apbct_hint_data__pixel' => array( + 'utm_id' => '', + 'utm_term' => '', + 'utm_source' => 'apbct_hint_data__pixel', + 'utm_medium' => 'WordPress', + 'utm_campaign' => 'ABPCT_Settings', + ), + 'apbct_hint_data__honeypot_field' => array( + 'utm_id' => '', + 'utm_term' => '', + 'utm_source' => 'apbct_hint_data__honeypot_field', + 'utm_medium' => 'WordPress', + 'utm_campaign' => 'ABPCT_Settings', + ), + 'apbct_hint_sfw__enabled' => array( + 'utm_id' => '', + 'utm_term' => '', + 'utm_source' => 'apbct_hint_sfw__enabled', + 'utm_medium' => 'WordPress', + 'utm_campaign' => 'ABPCT_Settings', + ), + /* + * Settings page links + */ + 'settings_support_open' => array( + 'utm_id' => '', + 'utm_term' => '', + 'utm_source' => 'admin_panel', + 'utm_medium' => 'settings', + 'utm_content' => 'sfw_support_link', + ), + 'blog_search_form_protection' => array( + 'utm_id' => '', + 'utm_term' => '', + 'utm_source' => 'admin_panel', + 'utm_medium' => 'settings', + 'utm_content' => 'search_form_protection', + ), + 'settings_dashboard_link' => array( + 'utm_id' => '', + 'utm_term' => '', + 'utm_source' => 'wp-backend', + 'utm_medium' => 'admin-bar', + 'utm_content' => 'settings_dashboard_link', + ), + 'settings_email_existence_alert' => array( + 'utm_id' => '', + 'utm_term' => '', + 'utm_source' => 'admin_panel', + 'utm_medium' => 'settings', + 'utm_content' => 'email_existence_alert', + ), + 'settings_hoster_api_key' => array( + 'utm_id' => '', + 'utm_term' => '', + 'utm_source' => 'admin_panel', + 'utm_medium' => 'settings', + 'utm_content' => 'hoster_api_key', + ), + 'settings_cloud_dashboard_button' => array( + 'utm_id' => '', + 'utm_term' => '', + 'utm_source' => 'admin_panel', + 'utm_medium' => 'settings', + 'utm_content' => 'cloud_dashboard_button', + ), + 'blog_email_validation_status' => array( + 'utm_id' => '', + 'utm_term' => '', + 'utm_source' => 'admin_panel', + 'utm_medium' => 'settings', + 'utm_content' => 'email_validation_status', + ), + 'settings_public_offer' => array( + 'utm_id' => '', + 'utm_term' => '', + 'utm_source' => 'admin_panel', + 'utm_medium' => 'settings', + 'utm_content' => 'public_offer', + ), ); public static function buildCleanTalkLink($utm_preset, $uri = '', $get_params = array(), $domain = 'https://cleantalk.org') diff --git a/tests/ApbctWP/TestSettingsLongDescriptionLinks.php b/tests/ApbctWP/TestSettingsLongDescriptionLinks.php new file mode 100644 index 000000000..fd6b218c7 --- /dev/null +++ b/tests/ApbctWP/TestSettingsLongDescriptionLinks.php @@ -0,0 +1,643 @@ +assertStringStartsWith('https://cleantalk.org/help/set-cookies-option?', $link); + $this->assertStringContainsString('utm_source=apbct_hint_data__set_cookies', $link); + $this->assertStringContainsString('utm_medium=WordPress', $link); + $this->assertStringContainsString('utm_campaign=ABPCT_Settings', $link); + } + + public function testPresetDataSetCookiesExists() + { + $this->assertArrayHasKey('apbct_hint_data__set_cookies', LinkConstructor::$utm_presets); + $preset = LinkConstructor::$utm_presets['apbct_hint_data__set_cookies']; + $this->assertSame('apbct_hint_data__set_cookies', $preset['utm_source']); + $this->assertSame('WordPress', $preset['utm_medium']); + $this->assertSame('ABPCT_Settings', $preset['utm_campaign']); + } + + /** + * comments__hide_website_field: help/how-to-hide-website-field-in-wordpress-comments + * Original: https://cleantalk.org/help/how-to-hide-website-field-in-wordpress-comments?utm_source=apbct_hint_comments__hide_website_field&utm_medium=hide_website_field_hint&utm_campaign=apbct_links + */ + public function testLinkCommentsHideWebsiteField() + { + $link = LinkConstructor::buildCleanTalkLink( + 'apbct_hint_comments__hide_website_field', + 'help/how-to-hide-website-field-in-wordpress-comments' + ); + + $this->assertStringStartsWith('https://cleantalk.org/help/how-to-hide-website-field-in-wordpress-comments?', $link); + $this->assertStringContainsString('utm_source=apbct_hint_comments__hide_website_field', $link); + $this->assertStringContainsString('utm_medium=hide_website_field_hint', $link); + $this->assertStringContainsString('utm_campaign=apbct_links', $link); + } + + public function testPresetCommentsHideWebsiteFieldExists() + { + $this->assertArrayHasKey('apbct_hint_comments__hide_website_field', LinkConstructor::$utm_presets); + $preset = LinkConstructor::$utm_presets['apbct_hint_comments__hide_website_field']; + $this->assertSame('apbct_hint_comments__hide_website_field', $preset['utm_source']); + $this->assertSame('hide_website_field_hint', $preset['utm_medium']); + // utm_campaign not in preset — should fall back to static::$utm_campaign = 'apbct_links' + $this->assertArrayNotHasKey('utm_campaign', $preset); + } + + /** + * comments__the_real_person: the-real-person (was already using LinkConstructor) + * Original: https://cleantalk.org/the-real-person?...&utm_source=admin_side&utm_medium=trp_badge&utm_content=trp_badge_link_click&utm_campaign=apbct_links + */ + public function testLinkCommentsTheRealPerson() + { + $link = LinkConstructor::buildCleanTalkLink('trp_learn_more_link', 'the-real-person'); + + $this->assertStringStartsWith('https://cleantalk.org/the-real-person?', $link); + $this->assertStringContainsString('utm_source=admin_side', $link); + $this->assertStringContainsString('utm_medium=trp_badge', $link); + $this->assertStringContainsString('utm_content=trp_badge_link_click', $link); + $this->assertStringContainsString('utm_campaign=apbct_links', $link); + } + + /** + * sfw__anti_crawler: help/anti-flood-and-anti-crawler + #anticrawl anchor + * Original: https://cleantalk.org/help/anti-flood-and-anti-crawler?utm_source=apbct_hint_sfw__anti_crawler&utm_medium=WordPress&utm_campaign=ABPCT_Settings + * Note: #anticrawl is appended outside of buildCleanTalkLink + */ + public function testLinkSfwAntiCrawler() + { + $link = LinkConstructor::buildCleanTalkLink('apbct_hint_sfw__anti_crawler', 'help/anti-flood-and-anti-crawler'); + + $this->assertStringStartsWith('https://cleantalk.org/help/anti-flood-and-anti-crawler?', $link); + $this->assertStringContainsString('utm_source=apbct_hint_sfw__anti_crawler', $link); + $this->assertStringContainsString('utm_medium=WordPress', $link); + $this->assertStringContainsString('utm_campaign=ABPCT_Settings', $link); + + // Verify anchor can be appended + $linkWithAnchor = $link . '#anticrawl'; + $this->assertStringEndsWith('#anticrawl', $linkWithAnchor); + } + + public function testPresetSfwAntiCrawlerExists() + { + $this->assertArrayHasKey('apbct_hint_sfw__anti_crawler', LinkConstructor::$utm_presets); + $preset = LinkConstructor::$utm_presets['apbct_hint_sfw__anti_crawler']; + $this->assertSame('apbct_hint_sfw__anti_crawler', $preset['utm_source']); + $this->assertSame('WordPress', $preset['utm_medium']); + $this->assertSame('ABPCT_Settings', $preset['utm_campaign']); + } + + /** + * sfw__anti_flood: help/anti-flood-and-anti-crawler + #antiflood anchor + * Original: https://cleantalk.org/help/anti-flood-and-anti-crawler?utm_source=apbct_hint_sfw__anti_flood&utm_medium=WordPress&utm_campaign=ABPCT_Settings + */ + public function testLinkSfwAntiFlood() + { + $link = LinkConstructor::buildCleanTalkLink('apbct_hint_sfw__anti_flood', 'help/anti-flood-and-anti-crawler'); + + $this->assertStringStartsWith('https://cleantalk.org/help/anti-flood-and-anti-crawler?', $link); + $this->assertStringContainsString('utm_source=apbct_hint_sfw__anti_flood', $link); + $this->assertStringContainsString('utm_medium=WordPress', $link); + $this->assertStringContainsString('utm_campaign=ABPCT_Settings', $link); + + // Verify anchor can be appended + $linkWithAnchor = $link . '#antiflood'; + $this->assertStringEndsWith('#antiflood', $linkWithAnchor); + } + + public function testPresetSfwAntiFloodExists() + { + $this->assertArrayHasKey('apbct_hint_sfw__anti_flood', LinkConstructor::$utm_presets); + $preset = LinkConstructor::$utm_presets['apbct_hint_sfw__anti_flood']; + $this->assertSame('apbct_hint_sfw__anti_flood', $preset['utm_source']); + $this->assertSame('WordPress', $preset['utm_medium']); + $this->assertSame('ABPCT_Settings', $preset['utm_campaign']); + } + + /** + * data__pixel: introducing-cleantalk-pixel (blog.cleantalk.org domain) + * Original: https://blog.cleantalk.org/introducing-cleantalk-pixel?utm_source=apbct_hint_data__pixel&utm_medium=WordPress&utm_campaign=ABPCT_Settings + */ + public function testLinkDataPixel() + { + $link = LinkConstructor::buildCleanTalkLink( + 'apbct_hint_data__pixel', + 'introducing-cleantalk-pixel', + array(), + 'https://blog.cleantalk.org' + ); + + $this->assertStringStartsWith('https://blog.cleantalk.org/introducing-cleantalk-pixel?', $link); + $this->assertStringContainsString('utm_source=apbct_hint_data__pixel', $link); + $this->assertStringContainsString('utm_medium=WordPress', $link); + $this->assertStringContainsString('utm_campaign=ABPCT_Settings', $link); + } + + public function testPresetDataPixelExists() + { + $this->assertArrayHasKey('apbct_hint_data__pixel', LinkConstructor::$utm_presets); + $preset = LinkConstructor::$utm_presets['apbct_hint_data__pixel']; + $this->assertSame('apbct_hint_data__pixel', $preset['utm_source']); + $this->assertSame('WordPress', $preset['utm_medium']); + $this->assertSame('ABPCT_Settings', $preset['utm_campaign']); + } + + /** + * data__honeypot_field: help/honeypot-field + * Original: https://cleantalk.org/help/honeypot-field?utm_source=apbct_hint_data__honeypot_field&utm_medium=WordPress&utm_campaign=ABPCT_Settings + */ + public function testLinkDataHoneypotField() + { + $link = LinkConstructor::buildCleanTalkLink('apbct_hint_data__honeypot_field', 'help/honeypot-field'); + + $this->assertStringStartsWith('https://cleantalk.org/help/honeypot-field?', $link); + $this->assertStringContainsString('utm_source=apbct_hint_data__honeypot_field', $link); + $this->assertStringContainsString('utm_medium=WordPress', $link); + $this->assertStringContainsString('utm_campaign=ABPCT_Settings', $link); + } + + public function testPresetDataHoneypotFieldExists() + { + $this->assertArrayHasKey('apbct_hint_data__honeypot_field', LinkConstructor::$utm_presets); + $preset = LinkConstructor::$utm_presets['apbct_hint_data__honeypot_field']; + $this->assertSame('apbct_hint_data__honeypot_field', $preset['utm_source']); + $this->assertSame('WordPress', $preset['utm_medium']); + $this->assertSame('ABPCT_Settings', $preset['utm_campaign']); + } + + /** + * sfw__enabled: help/anti-flood-and-anti-crawler + * Original: https://cleantalk.org/help/anti-flood-and-anti-crawler?utm_source=apbct_hint_sfw__enabled&utm_medium=WordPress&utm_campaign=ABPCT_Settings + */ + public function testLinkSfwEnabled() + { + $link = LinkConstructor::buildCleanTalkLink('apbct_hint_sfw__enabled', 'help/anti-flood-and-anti-crawler'); + + $this->assertStringStartsWith('https://cleantalk.org/help/anti-flood-and-anti-crawler?', $link); + $this->assertStringContainsString('utm_source=apbct_hint_sfw__enabled', $link); + $this->assertStringContainsString('utm_medium=WordPress', $link); + $this->assertStringContainsString('utm_campaign=ABPCT_Settings', $link); + } + + public function testPresetSfwEnabledExists() + { + $this->assertArrayHasKey('apbct_hint_sfw__enabled', LinkConstructor::$utm_presets); + $preset = LinkConstructor::$utm_presets['apbct_hint_sfw__enabled']; + $this->assertSame('apbct_hint_sfw__enabled', $preset['utm_source']); + $this->assertSame('WordPress', $preset['utm_medium']); + $this->assertSame('ABPCT_Settings', $preset['utm_campaign']); + } + + /** + * exclusions__form_signs: help/exclusion-by-form-signs (was already using LinkConstructor) + * Original: https://cleantalk.org/help/exclusion-by-form-signs?...&utm_source=admin_panel&utm_medium=settings&utm_content=apbct_hint_exclusions__form_signs&utm_campaign=apbct_links + */ + public function testLinkExclusionsFormSigns() + { + $link = LinkConstructor::buildCleanTalkLink('exclusion_by_form_signs', 'help/exclusion-by-form-signs'); + + $this->assertStringStartsWith('https://cleantalk.org/help/exclusion-by-form-signs?', $link); + $this->assertStringContainsString('utm_source=admin_panel', $link); + $this->assertStringContainsString('utm_medium=settings', $link); + $this->assertStringContainsString('utm_content=apbct_hint_exclusions__form_signs', $link); + $this->assertStringContainsString('utm_campaign=apbct_links', $link); + } + + + + // ========================================================================= + // Structural tests: verify LinkConstructor correctly builds URLs + // ========================================================================= + + /** + * Ensure all hint presets produce URLs with correct domain + */ + public function testAllHintPresetsUseCleantalkDomain() + { + $hintPresets = array( + 'apbct_hint_data__set_cookies', + 'apbct_hint_comments__hide_website_field', + 'apbct_hint_sfw__anti_crawler', + 'apbct_hint_sfw__anti_flood', + 'apbct_hint_data__honeypot_field', + 'apbct_hint_sfw__enabled', + ); + + foreach ($hintPresets as $preset) { + $link = LinkConstructor::buildCleanTalkLink($preset, 'test-uri'); + $this->assertStringStartsWith( + 'https://cleantalk.org/test-uri?', + $link, + "Preset '$preset' should produce URL with cleantalk.org domain" + ); + } + } + + /** + * Ensure data__pixel preset works with blog domain + */ + public function testPixelPresetWithBlogDomain() + { + $link = LinkConstructor::buildCleanTalkLink( + 'apbct_hint_data__pixel', + 'test-uri', + array(), + 'https://blog.cleantalk.org' + ); + $this->assertStringStartsWith('https://blog.cleantalk.org/test-uri?', $link); + $this->assertStringNotContainsString('https://cleantalk.org', $link); + } + + /** + * Verify that all long description presets exist in LinkConstructor + */ + public function testAllLongDescriptionPresetsExist() + { + $requiredPresets = array( + 'apbct_hint_data__set_cookies', + 'apbct_hint_comments__hide_website_field', + 'apbct_hint_sfw__anti_crawler', + 'apbct_hint_sfw__anti_flood', + 'apbct_hint_data__pixel', + 'apbct_hint_data__honeypot_field', + 'apbct_hint_sfw__enabled', + 'trp_learn_more_link', + 'exclusion_by_form_signs', + ); + + foreach ($requiredPresets as $preset) { + $this->assertArrayHasKey( + $preset, + LinkConstructor::$utm_presets, + "UTM preset '$preset' must exist in LinkConstructor::\$utm_presets" + ); + } + } + + /** + * Verify that UTM campaign defaults to 'apbct_links' when not specified in preset + */ + public function testDefaultUtmCampaignFallback() + { + // comments__hide_website_field preset has no utm_campaign + $link = LinkConstructor::buildCleanTalkLink( + 'apbct_hint_comments__hide_website_field', + 'test' + ); + $this->assertStringContainsString('utm_campaign=apbct_links', $link); + } + + /** + * Verify that UTM campaign from preset overrides default + */ + public function testCustomUtmCampaignInPreset() + { + // data__set_cookies preset has utm_campaign=ABPCT_Settings + $link = LinkConstructor::buildCleanTalkLink( + 'apbct_hint_data__set_cookies', + 'test' + ); + $this->assertStringContainsString('utm_campaign=ABPCT_Settings', $link); + $this->assertStringNotContainsString('utm_campaign=apbct_links', $link); + } + + /** + * Verify invalid preset throws exception + */ + public function testInvalidPresetThrowsException() + { + $this->expectException(\Exception::class); + LinkConstructor::buildCleanTalkLink('nonexistent_preset', 'test'); + } + + /** + * Full URL exact match tests for regression detection. + * These are the canonical URLs that must not change. + */ + public function testExactUrlDataSetCookies() + { + $link = LinkConstructor::buildCleanTalkLink('apbct_hint_data__set_cookies', 'help/set-cookies-option'); + $this->assertSame( + 'https://cleantalk.org/help/set-cookies-option?utm_id=&utm_term=&utm_source=apbct_hint_data__set_cookies&utm_medium=WordPress&utm_campaign=ABPCT_Settings', + $link + ); + } + + public function testExactUrlCommentsHideWebsiteField() + { + $link = LinkConstructor::buildCleanTalkLink( + 'apbct_hint_comments__hide_website_field', + 'help/how-to-hide-website-field-in-wordpress-comments' + ); + $this->assertSame( + 'https://cleantalk.org/help/how-to-hide-website-field-in-wordpress-comments?utm_id=&utm_term=&utm_source=apbct_hint_comments__hide_website_field&utm_medium=hide_website_field_hint&utm_campaign=apbct_links', + $link + ); + } + + public function testExactUrlSfwAntiCrawler() + { + $link = LinkConstructor::buildCleanTalkLink('apbct_hint_sfw__anti_crawler', 'help/anti-flood-and-anti-crawler'); + $this->assertSame( + 'https://cleantalk.org/help/anti-flood-and-anti-crawler?utm_id=&utm_term=&utm_source=apbct_hint_sfw__anti_crawler&utm_medium=WordPress&utm_campaign=ABPCT_Settings', + $link + ); + } + + public function testExactUrlSfwAntiFlood() + { + $link = LinkConstructor::buildCleanTalkLink('apbct_hint_sfw__anti_flood', 'help/anti-flood-and-anti-crawler'); + $this->assertSame( + 'https://cleantalk.org/help/anti-flood-and-anti-crawler?utm_id=&utm_term=&utm_source=apbct_hint_sfw__anti_flood&utm_medium=WordPress&utm_campaign=ABPCT_Settings', + $link + ); + } + + public function testExactUrlDataPixel() + { + $link = LinkConstructor::buildCleanTalkLink( + 'apbct_hint_data__pixel', + 'introducing-cleantalk-pixel', + array(), + 'https://blog.cleantalk.org' + ); + $this->assertSame( + 'https://blog.cleantalk.org/introducing-cleantalk-pixel?utm_id=&utm_term=&utm_source=apbct_hint_data__pixel&utm_medium=WordPress&utm_campaign=ABPCT_Settings', + $link + ); + } + + public function testExactUrlDataHoneypotField() + { + $link = LinkConstructor::buildCleanTalkLink('apbct_hint_data__honeypot_field', 'help/honeypot-field'); + $this->assertSame( + 'https://cleantalk.org/help/honeypot-field?utm_id=&utm_term=&utm_source=apbct_hint_data__honeypot_field&utm_medium=WordPress&utm_campaign=ABPCT_Settings', + $link + ); + } + + public function testExactUrlSfwEnabled() + { + $link = LinkConstructor::buildCleanTalkLink('apbct_hint_sfw__enabled', 'help/anti-flood-and-anti-crawler'); + $this->assertSame( + 'https://cleantalk.org/help/anti-flood-and-anti-crawler?utm_id=&utm_term=&utm_source=apbct_hint_sfw__enabled&utm_medium=WordPress&utm_campaign=ABPCT_Settings', + $link + ); + } + + public function testExactUrlExclusionByFormSigns() + { + $link = LinkConstructor::buildCleanTalkLink('exclusion_by_form_signs', 'help/exclusion-by-form-signs'); + $this->assertSame( + 'https://cleantalk.org/help/exclusion-by-form-signs?utm_id=&utm_term=&utm_source=admin_panel&utm_medium=settings&utm_content=apbct_hint_exclusions__form_signs&utm_campaign=apbct_links', + $link + ); + } + + public function testExactUrlTrpLearnMoreLink() + { + $link = LinkConstructor::buildCleanTalkLink('trp_learn_more_link', 'the-real-person'); + $this->assertSame( + 'https://cleantalk.org/the-real-person?utm_id=&utm_term=&utm_source=admin_side&utm_medium=trp_badge&utm_content=trp_badge_link_click&utm_campaign=apbct_links', + $link + ); + } + + // ========================================================================= + // Tests for settings page links (second batch migration) + // ========================================================================= + + /** + * Line 116: Support link (SFW empty warning) + */ + public function testLinkSettingsSupportOpen() + { + $link = LinkConstructor::buildCleanTalkLink('settings_support_open', 'my/support/open'); + + $this->assertStringStartsWith('https://cleantalk.org/my/support/open?', $link); + $this->assertStringContainsString('utm_source=admin_panel', $link); + $this->assertStringContainsString('utm_medium=settings', $link); + $this->assertStringContainsString('utm_content=sfw_support_link', $link); + $this->assertStringContainsString('utm_campaign=apbct_links', $link); + } + + public function testExactUrlSettingsSupportOpen() + { + $link = LinkConstructor::buildCleanTalkLink('settings_support_open', 'my/support/open'); + $this->assertSame( + 'https://cleantalk.org/my/support/open?utm_id=&utm_term=&utm_source=admin_panel&utm_medium=settings&utm_content=sfw_support_link&utm_campaign=apbct_links', + $link + ); + } + + /** + * Line 291: Blog search form protection + */ + public function testLinkBlogSearchFormProtection() + { + $link = LinkConstructor::buildCleanTalkLink( + 'blog_search_form_protection', + 'how-to-protect-website-search-from-spambots', + array(), + 'https://blog.cleantalk.org' + ); + + $this->assertStringStartsWith('https://blog.cleantalk.org/how-to-protect-website-search-from-spambots?', $link); + $this->assertStringContainsString('utm_source=admin_panel', $link); + $this->assertStringContainsString('utm_medium=settings', $link); + $this->assertStringContainsString('utm_content=search_form_protection', $link); + } + + public function testExactUrlBlogSearchFormProtection() + { + $link = LinkConstructor::buildCleanTalkLink( + 'blog_search_form_protection', + 'how-to-protect-website-search-from-spambots', + array(), + 'https://blog.cleantalk.org' + ); + $this->assertSame( + 'https://blog.cleantalk.org/how-to-protect-website-search-from-spambots?utm_id=&utm_term=&utm_source=admin_panel&utm_medium=settings&utm_content=search_form_protection&utm_campaign=apbct_links', + $link + ); + } + + /** + * Line 530: Dashboard link with user_token and cp_mode + */ + public function testLinkSettingsDashboard() + { + $link = LinkConstructor::buildCleanTalkLink( + 'settings_dashboard_link', + 'my', + array('user_token' => 'test_token_123', 'cp_mode' => 'antispam') + ); + + $this->assertStringStartsWith('https://cleantalk.org/my?', $link); + $this->assertStringContainsString('user_token=test_token_123', $link); + $this->assertStringContainsString('cp_mode=antispam', $link); + $this->assertStringContainsString('utm_source=wp-backend', $link); + $this->assertStringContainsString('utm_medium=admin-bar', $link); + $this->assertStringContainsString('utm_content=settings_dashboard_link', $link); + } + + /** + * Line 668: Email existence alert help link + */ + public function testLinkSettingsEmailExistenceAlert() + { + $link = LinkConstructor::buildCleanTalkLink('settings_email_existence_alert', 'help/show-email-existence-alert'); + + $this->assertStringStartsWith('https://cleantalk.org/help/show-email-existence-alert?', $link); + $this->assertStringContainsString('utm_source=admin_panel', $link); + $this->assertStringContainsString('utm_medium=settings', $link); + $this->assertStringContainsString('utm_content=email_existence_alert', $link); + } + + public function testExactUrlSettingsEmailExistenceAlert() + { + $link = LinkConstructor::buildCleanTalkLink('settings_email_existence_alert', 'help/show-email-existence-alert'); + $this->assertSame( + 'https://cleantalk.org/help/show-email-existence-alert?utm_id=&utm_term=&utm_source=admin_panel&utm_medium=settings&utm_content=email_existence_alert&utm_campaign=apbct_links', + $link + ); + } + + /** + * Line 1129: Hoster API key profile link with #api_keys anchor + */ + public function testLinkSettingsHosterApiKey() + { + $link = LinkConstructor::buildCleanTalkLink('settings_hoster_api_key', 'my/profile'); + + $this->assertStringStartsWith('https://cleantalk.org/my/profile?', $link); + $this->assertStringContainsString('utm_source=admin_panel', $link); + $this->assertStringContainsString('utm_medium=settings', $link); + $this->assertStringContainsString('utm_content=hoster_api_key', $link); + + // Verify anchor can be appended + $linkWithAnchor = $link . '#api_keys'; + $this->assertStringEndsWith('#api_keys', $linkWithAnchor); + } + + /** + * Line 1374: Cloud Dashboard button with user_token + */ + public function testLinkSettingsCloudDashboardButton() + { + $link = LinkConstructor::buildCleanTalkLink( + 'settings_cloud_dashboard_button', + 'my', + array('user_token' => 'abc123', 'cp_mode' => 'antispam') + ); + + $this->assertStringStartsWith('https://cleantalk.org/my?', $link); + $this->assertStringContainsString('user_token=abc123', $link); + $this->assertStringContainsString('cp_mode=antispam', $link); + $this->assertStringContainsString('utm_source=admin_panel', $link); + $this->assertStringContainsString('utm_medium=settings', $link); + $this->assertStringContainsString('utm_content=cloud_dashboard_button', $link); + } + + /** + * Line 1879: Blog email validation status link + */ + public function testLinkBlogEmailValidationStatus() + { + $link = LinkConstructor::buildCleanTalkLink( + 'blog_email_validation_status', + 'real-time-email-address-existence-validation', + array(), + 'https://blog.cleantalk.org' + ); + + $this->assertStringStartsWith('https://blog.cleantalk.org/real-time-email-address-existence-validation?', $link); + $this->assertStringContainsString('utm_source=admin_panel', $link); + $this->assertStringContainsString('utm_medium=settings', $link); + $this->assertStringContainsString('utm_content=email_validation_status', $link); + } + + public function testExactUrlBlogEmailValidationStatus() + { + $link = LinkConstructor::buildCleanTalkLink( + 'blog_email_validation_status', + 'real-time-email-address-existence-validation', + array(), + 'https://blog.cleantalk.org' + ); + $this->assertSame( + 'https://blog.cleantalk.org/real-time-email-address-existence-validation?utm_id=&utm_term=&utm_source=admin_panel&utm_medium=settings&utm_content=email_validation_status&utm_campaign=apbct_links', + $link + ); + } + + /** + * Line 2013: Public offer link + */ + public function testLinkSettingsPublicOffer() + { + $link = LinkConstructor::buildCleanTalkLink('settings_public_offer', 'publicoffer'); + + $this->assertStringStartsWith('https://cleantalk.org/publicoffer?', $link); + $this->assertStringContainsString('utm_source=admin_panel', $link); + $this->assertStringContainsString('utm_medium=settings', $link); + $this->assertStringContainsString('utm_content=public_offer', $link); + } + + public function testExactUrlSettingsPublicOffer() + { + $link = LinkConstructor::buildCleanTalkLink('settings_public_offer', 'publicoffer'); + $this->assertSame( + 'https://cleantalk.org/publicoffer?utm_id=&utm_term=&utm_source=admin_panel&utm_medium=settings&utm_content=public_offer&utm_campaign=apbct_links', + $link + ); + } + + /** + * Verify all new settings page presets exist + */ + public function testAllSettingsPagePresetsExist() + { + $requiredPresets = array( + 'settings_support_open', + 'blog_search_form_protection', + 'settings_dashboard_link', + 'settings_email_existence_alert', + 'settings_hoster_api_key', + 'settings_cloud_dashboard_button', + 'blog_email_validation_status', + 'settings_public_offer', + ); + + foreach ($requiredPresets as $preset) { + $this->assertArrayHasKey( + $preset, + LinkConstructor::$utm_presets, + "UTM preset '$preset' must exist in LinkConstructor::\$utm_presets" + ); + } + } +} From ab927803c6101f3eae48dfea09ec288ddf3fce77 Mon Sep 17 00:00:00 2001 From: AntonV1211 Date: Wed, 1 Jul 2026 18:05:55 +0700 Subject: [PATCH 21/35] Unittests --- inc/cleantalk-settings.php | 24 ++- tests/Inc/TestCleantalkSettingsLinks.php | 216 +++++++++++++++++++++++ 2 files changed, 235 insertions(+), 5 deletions(-) create mode 100644 tests/Inc/TestCleantalkSettingsLinks.php diff --git a/inc/cleantalk-settings.php b/inc/cleantalk-settings.php index fd19985c2..32f7d75b0 100644 --- a/inc/cleantalk-settings.php +++ b/inc/cleantalk-settings.php @@ -3019,6 +3019,24 @@ function apbct_settings__get__long_description() } $setting_id = TT::toString(Post::get('setting_id', null, 'word')); + $descriptions = apbct_settings__get_long_descriptions_data(); + + if (!empty($setting_id) && isset($descriptions[$setting_id])) { + die(json_encode($descriptions[$setting_id])); + } else { + die(json_encode(['error' => 'Invalid setting ID'])); + } +} + +/** + * Builds the long descriptions data array with all LinkConstructor links. + * Extracted for testability. + * + * @return array + */ +function apbct_settings__get_long_descriptions_data() +{ + global $apbct; $link_exclusion_by_form_signs = LinkConstructor::buildCleanTalkLink( 'exclusion_by_form_signs', @@ -3175,11 +3193,7 @@ function apbct_settings__get__long_description() ), ); - if (!empty($setting_id) && isset($descriptions[$setting_id])) { - die(json_encode($descriptions[$setting_id])); - } else { - die(json_encode(['error' => 'Invalid setting ID'])); - } + return $descriptions; } function apbct_settings__check_renew_banner() diff --git a/tests/Inc/TestCleantalkSettingsLinks.php b/tests/Inc/TestCleantalkSettingsLinks.php new file mode 100644 index 000000000..9fdf4bdea --- /dev/null +++ b/tests/Inc/TestCleantalkSettingsLinks.php @@ -0,0 +1,216 @@ +data['bot_detector_enabled'] = 1; + $apbct->data['wl_brandname'] = 'Anti-Spam by CleanTalk'; + $apbct->data['wl_mode_enabled'] = false; + $apbct->data['moderate'] = 1; + $apbct->data['moderate_ip'] = 0; + $apbct->data['ip_license'] = 0; + $this->apbct = $apbct; + } + + protected function tearDown(): void + { + global $apbct; + unset($apbct); + + // Reset Post singleton cache so $_POST changes are visible between tests + $instance = Post::getInstance(); + $instance->variables = array(); + + parent::tearDown(); + } + + // ========================================================================= + // apbct_settings__set_fields() — covers lines 115, 290, 530, 668 + // ========================================================================= + + /** + * Line 290 (blog_search_form_protection) - inside `! $apbct->white_label || is_main_site()` + * Line 530 (settings_dashboard_link) - inside `! $apbct->white_label && ! $apbct->data["wl_mode_enabled"]` + * Line 668 (settings_email_existence_alert) - unconditional + */ + public function testSetFieldsCoversSearchAndDashboardAndEmailLinks() + { + $fields = apbct_settings__set_fields(); + + // Line 290: forms__search_test description should contain the blog link + $this->assertArrayHasKey('forms_protection', $fields); + $search_field = $fields['forms_protection']['fields']['forms__search_test']; + $this->assertStringContainsString('blog.cleantalk.org', $search_field['description']); + $this->assertStringContainsString('utm_content=search_form_protection', $search_field['description']); + + // Line 530: data__general_postdata_test description should contain dashboard link + $this->assertArrayHasKey('data_processing', $fields); + $postdata_field = $fields['data_processing']['fields']['data__general_postdata_test']; + $this->assertStringContainsString('cleantalk.org/my', $postdata_field['description']); + $this->assertStringContainsString('utm_content=settings_dashboard_link', $postdata_field['description']); + + // Line 668: data__email_check_exist_post description should contain email alert link + $email_field = $fields['data_processing']['fields']['data__email_check_exist_post']; + $this->assertStringContainsString('help/show-email-existence-alert', $email_field['description']); + $this->assertStringContainsString('utm_content=email_existence_alert', $email_field['description']); + } + + /** + * Line 115 (settings_support_open) - inside condition: + * $apbct->api_key && is_null($apbct->fw_stats['firewall_updating_id']) + * && $apbct->settings['sfw__enabled'] && ! $apbct->stats['sfw']['entries'] + */ + public function testSetFieldsCoversSupportOpenLink() + { + global $apbct; + + // Set up conditions to hit line 115 + $apbct->storage['api_key'] = 'test_key_12345678'; + $apbct->storage['fw_stats'] = array('firewall_updating_id' => null); + $apbct->storage['settings'] = array_merge( + $apbct->default_settings, + array('sfw__enabled' => 1) + ); + $apbct->storage['stats'] = array('sfw' => array('entries' => 0)); + + $fields = apbct_settings__set_fields(); + + // The anti_crawler title should contain the support link + $anti_crawler = $fields['different']['fields']['sfw__anti_crawler']; + $this->assertStringContainsString('my/support/open', $anti_crawler['title']); + $this->assertStringContainsString('utm_content=sfw_support_link', $anti_crawler['title']); + } + + // ========================================================================= + // apbct_settings__field__state() — covers line 1873 + // ========================================================================= + + /** + * Line 1873 (blog_email_validation_status) - inside `! $apbct->white_label || is_main_site()` + */ + public function testFieldStateCoversEmailValidationLink() + { + ob_start(); + apbct_settings__field__state(); + $output = ob_get_clean(); + + $this->assertStringContainsString('blog.cleantalk.org', $output); + $this->assertStringContainsString('real-time-email-address-existence-validation', $output); + $this->assertStringContainsString('utm_content=email_validation_status', $output); + } + + // ========================================================================= + // apbct_settings__field__apikey() — covers line 2007 + // ========================================================================= + + /** + * Line 2007 (settings_public_offer) - unconditional after passing early returns. + * Needs: apbct__is_hosting_license() = false, not WPMS sub-site + */ + public function testFieldApikeyCoversPublicOfferLink() + { + global $apbct; + $apbct->data['moderate_ip'] = 0; + $apbct->data['ip_license'] = 0; + + ob_start(); + apbct_settings__field__apikey(); + $output = ob_get_clean(); + + $this->assertStringContainsString('publicoffer', $output); + $this->assertStringContainsString('utm_content=public_offer', $output); + } + + // ========================================================================= + // apbct_settings__get_long_descriptions_data() — covers lines 3045-3104 + // ========================================================================= + + /** + * Covers all LinkConstructor calls in long descriptions array. + * Lines: 3045, 3052, 3057, 3070, 3079, 3086, 3093, 3104 + * + * Calls the extracted helper function directly (no die/wp_die involved). + */ + public function testLongDescriptionCoversAllLinks() + { + $descriptions = apbct_settings__get_long_descriptions_data(); + + $this->assertIsArray($descriptions); + $this->assertArrayHasKey('data__set_cookies', $descriptions); + $this->assertStringContainsString('help/set-cookies-option', $descriptions['data__set_cookies']['desc']); + $this->assertStringContainsString('apbct_hint_data__set_cookies', $descriptions['data__set_cookies']['desc']); + } + + /** + * Test anti_crawler long description link with anchor. + */ + public function testLongDescriptionAntiCrawlerLink() + { + $descriptions = apbct_settings__get_long_descriptions_data(); + + $this->assertArrayHasKey('sfw__anti_crawler', $descriptions); + $this->assertStringContainsString('help/anti-flood-and-anti-crawler', $descriptions['sfw__anti_crawler']['desc']); + $this->assertStringContainsString('#anticrawl', $descriptions['sfw__anti_crawler']['desc']); + } + + /** + * Test sfw__enabled long description (line 3104). + */ + public function testLongDescriptionSfwEnabledLink() + { + $descriptions = apbct_settings__get_long_descriptions_data(); + + $this->assertArrayHasKey('sfw__enabled', $descriptions); + $this->assertStringContainsString('help/anti-flood-and-anti-crawler', $descriptions['sfw__enabled']['desc']); + $this->assertStringContainsString('apbct_hint_sfw__enabled', $descriptions['sfw__enabled']['desc']); + } + + /** + * Test data__pixel long description (line 3086) with blog.cleantalk.org domain. + */ + public function testLongDescriptionPixelLink() + { + $descriptions = apbct_settings__get_long_descriptions_data(); + + $this->assertArrayHasKey('data__pixel', $descriptions); + $this->assertStringContainsString('blog.cleantalk.org', $descriptions['data__pixel']['desc']); + $this->assertStringContainsString('introducing-cleantalk-pixel', $descriptions['data__pixel']['desc']); + } + + /** + * Test data__honeypot_field long description (line 3093). + */ + public function testLongDescriptionHoneypotLink() + { + $descriptions = apbct_settings__get_long_descriptions_data(); + + $this->assertArrayHasKey('data__honeypot_field', $descriptions); + $this->assertStringContainsString('help/honeypot-field', $descriptions['data__honeypot_field']['desc']); + $this->assertStringContainsString('apbct_hint_data__honeypot_field', $descriptions['data__honeypot_field']['desc']); + } +} From a20131d0738b3ad328df1dab8ff2964fec8d4ff0 Mon Sep 17 00:00:00 2001 From: AntonV1211 <39616556+AntonV1211@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:00:22 +0700 Subject: [PATCH 22/35] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- inc/cleantalk-settings.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/inc/cleantalk-settings.php b/inc/cleantalk-settings.php index 32f7d75b0..4dbf76c49 100644 --- a/inc/cleantalk-settings.php +++ b/inc/cleantalk-settings.php @@ -3066,7 +3066,7 @@ function apbct_settings__get_long_descriptions_data() 'comments__hide_website_field' => array( 'title' => __('Hide the "Website" field', 'cleantalk-spam-protect'), 'desc' => sprintf( - __('This «Website» field is frequently used by spammers to place spam links in it. ' . esc_html__($apbct->data['wl_brandname']) . ' helps you protect your WordPress website comments by hiding this field off. %s', 'cleantalk-spam-protect'), + __('This «Website» field is frequently used by spammers to place spam links in it. ' . esc_html($apbct->data['wl_brandname']) . ' helps you protect your WordPress website comments by hiding this field off. %s', 'cleantalk-spam-protect'), '' . __('Learn more.', 'cleantalk-spam-protect') . '' ) ), From 8cf708c0df7cf91f2c857b5152215df7c025525e Mon Sep 17 00:00:00 2001 From: AntonV1211 <39616556+AntonV1211@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:06:49 +0700 Subject: [PATCH 23/35] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- inc/cleantalk-settings.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/inc/cleantalk-settings.php b/inc/cleantalk-settings.php index 4dbf76c49..8bd6d84b6 100644 --- a/inc/cleantalk-settings.php +++ b/inc/cleantalk-settings.php @@ -3093,7 +3093,7 @@ function apbct_settings__get_long_descriptions_data() 'sfw__anti_flood' => array( 'title' => 'Anti-Flood', // Do not to localize this phrase 'desc' => sprintf( - __(esc_html__($apbct->data['wl_brandname']) . ' Anti-Flood — this option is meant to block aggressive bots. You can set the maximum number of website pages your visitors can click on within 1 minute. If any IP exceeds the set number it will get the ' . $apbct->data['wl_brandname'] . ' blocking screen for 30 seconds. It\'s impossible for the IP to open any website pages while the 30-second timer takes place. %s', 'cleantalk-spam-protect'), + __(esc_html($apbct->data['wl_brandname']) . ' Anti-Flood — this option is meant to block aggressive bots. You can set the maximum number of website pages your visitors can click on within 1 minute. If any IP exceeds the set number it will get the ' . $apbct->data['wl_brandname'] . ' blocking screen for 30 seconds. It\'s impossible for the IP to open any website pages while the 30-second timer takes place. %s', 'cleantalk-spam-protect'), '' . __('Learn more.', 'cleantalk-spam-protect') . '' ) ), From 7222e40cb51071d651d09242c65c2527ff29d91f Mon Sep 17 00:00:00 2001 From: AntonV1211 <39616556+AntonV1211@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:07:02 +0700 Subject: [PATCH 24/35] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- inc/cleantalk-settings.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/inc/cleantalk-settings.php b/inc/cleantalk-settings.php index 8bd6d84b6..10b48f7e9 100644 --- a/inc/cleantalk-settings.php +++ b/inc/cleantalk-settings.php @@ -3098,7 +3098,7 @@ function apbct_settings__get_long_descriptions_data() ) ), 'data__pixel' => array( - 'title' => __(esc_html__($apbct->data['wl_brandname']) . ' Pixel', 'cleantalk-spam-protect'), + 'title' => __(esc_html($apbct->data['wl_brandname']) . ' Pixel', 'cleantalk-spam-protect'), 'desc' => sprintf( __('It is an «invisible» 1×1px image that the Anti-Spam plugin integrates to your WordPress website. And when someone visits your website the Pixel is triggered and reports this visit and some other data including true IP address. %s', 'cleantalk-spam-protect'), '' . __('Learn more.', 'cleantalk-spam-protect') . '' From 264bf8be01c97b5bc04008172af0ded80e573d9f Mon Sep 17 00:00:00 2001 From: AntonV1211 Date: Thu, 2 Jul 2026 14:11:28 +0700 Subject: [PATCH 25/35] fix esc --- inc/cleantalk-settings.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/inc/cleantalk-settings.php b/inc/cleantalk-settings.php index 10b48f7e9..573af51cb 100644 --- a/inc/cleantalk-settings.php +++ b/inc/cleantalk-settings.php @@ -3093,7 +3093,7 @@ function apbct_settings__get_long_descriptions_data() 'sfw__anti_flood' => array( 'title' => 'Anti-Flood', // Do not to localize this phrase 'desc' => sprintf( - __(esc_html($apbct->data['wl_brandname']) . ' Anti-Flood — this option is meant to block aggressive bots. You can set the maximum number of website pages your visitors can click on within 1 minute. If any IP exceeds the set number it will get the ' . $apbct->data['wl_brandname'] . ' blocking screen for 30 seconds. It\'s impossible for the IP to open any website pages while the 30-second timer takes place. %s', 'cleantalk-spam-protect'), + __(esc_html($apbct->data['wl_brandname']) . ' Anti-Flood — this option is meant to block aggressive bots. You can set the maximum number of website pages your visitors can click on within 1 minute. If any IP exceeds the set number it will get the ' . __(esc_html($apbct->data['wl_brandname'])) . ' blocking screen for 30 seconds. It\'s impossible for the IP to open any website pages while the 30-second timer takes place. %s', 'cleantalk-spam-protect'), '' . __('Learn more.', 'cleantalk-spam-protect') . '' ) ), @@ -3116,7 +3116,7 @@ function apbct_settings__get_long_descriptions_data() 'desc' => sprintf( '

' . esc_html__('SpamFireWall is a part of the anti-spam service and blocks the most spam active bots before the site pages load.', 'cleantalk-spam-protect') . '

' . '

' . esc_html__('Anti-Crawler is an add-on to SFW and helps to strengthen the protection against spam bots. Disabled by default.', 'cleantalk-spam-protect') . '

' - . '

' . esc_html__($apbct->data['wl_brandname'] . ' Anti-Flood is also an add-on to SFW and limits the number of pages visited per minute. Disabled by default.', 'cleantalk-spam-protect') . '

' + . '

' . esc_html($apbct->data['wl_brandname'] . ' Anti-Flood is also an add-on to SFW and limits the number of pages visited per minute. Disabled by default.', 'cleantalk-spam-protect') . '

' . '

' . esc_html__('You can read more about SFW modes %s', 'cleantalk-spam-protect') . '

' . '

' . esc_html__('Read out the article if you are using Varnish on your server.', 'cleantalk-spam-protect'), '' . __('here.', 'cleantalk-spam-protect') . '' From cf4b99cfc477dd72801a8dca9bf94872ca275f6c Mon Sep 17 00:00:00 2001 From: AntonV1211 Date: Thu, 2 Jul 2026 14:32:14 +0700 Subject: [PATCH 26/35] fix esc --- inc/cleantalk-settings.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/inc/cleantalk-settings.php b/inc/cleantalk-settings.php index 573af51cb..a8ea76ead 100644 --- a/inc/cleantalk-settings.php +++ b/inc/cleantalk-settings.php @@ -3093,7 +3093,7 @@ function apbct_settings__get_long_descriptions_data() 'sfw__anti_flood' => array( 'title' => 'Anti-Flood', // Do not to localize this phrase 'desc' => sprintf( - __(esc_html($apbct->data['wl_brandname']) . ' Anti-Flood — this option is meant to block aggressive bots. You can set the maximum number of website pages your visitors can click on within 1 minute. If any IP exceeds the set number it will get the ' . __(esc_html($apbct->data['wl_brandname'])) . ' blocking screen for 30 seconds. It\'s impossible for the IP to open any website pages while the 30-second timer takes place. %s', 'cleantalk-spam-protect'), + __(esc_html($apbct->data['wl_brandname']) . ' Anti-Flood — this option is meant to block aggressive bots. You can set the maximum number of website pages your visitors can click on within 1 minute. If any IP exceeds the set number it will get the ' . esc_html($apbct->data['wl_brandname']) . ' blocking screen for 30 seconds. It\'s impossible for the IP to open any website pages while the 30-second timer takes place. %s', 'cleantalk-spam-protect'), '' . __('Learn more.', 'cleantalk-spam-protect') . '' ) ), @@ -3116,7 +3116,7 @@ function apbct_settings__get_long_descriptions_data() 'desc' => sprintf( '

' . esc_html__('SpamFireWall is a part of the anti-spam service and blocks the most spam active bots before the site pages load.', 'cleantalk-spam-protect') . '

' . '

' . esc_html__('Anti-Crawler is an add-on to SFW and helps to strengthen the protection against spam bots. Disabled by default.', 'cleantalk-spam-protect') . '

' - . '

' . esc_html($apbct->data['wl_brandname'] . ' Anti-Flood is also an add-on to SFW and limits the number of pages visited per minute. Disabled by default.', 'cleantalk-spam-protect') . '

' + . '

' . esc_html($apbct->data['wl_brandname']) . __(' Anti-Flood is also an add-on to SFW and limits the number of pages visited per minute. Disabled by default.', 'cleantalk-spam-protect') . '

' . '

' . esc_html__('You can read more about SFW modes %s', 'cleantalk-spam-protect') . '

' . '

' . esc_html__('Read out the article if you are using Varnish on your server.', 'cleantalk-spam-protect'), '' . __('here.', 'cleantalk-spam-protect') . '' From f0c4500544ab2fcc30bc1a7fae3577aa4e7930bc Mon Sep 17 00:00:00 2001 From: datorik Date: Thu, 11 Jun 2026 18:00:33 +0300 Subject: [PATCH 27/35] Code. Proxy --- lib/Cleantalk/ApbctWP/API.php | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/Cleantalk/ApbctWP/API.php b/lib/Cleantalk/ApbctWP/API.php index 55024bf82..0c1c3b896 100644 --- a/lib/Cleantalk/ApbctWP/API.php +++ b/lib/Cleantalk/ApbctWP/API.php @@ -83,12 +83,21 @@ public static function sendRequest($data, $timeout = 10) // Adding agent version to data $data['agent'] = defined('APBCT_AGENT') ? APBCT_AGENT : ''; + $options = ['timeout' => $timeout]; + // Optional proxy (constants declared in wp-config.php). + if ( defined('CT_PROXY_HOST') && CT_PROXY_HOST !== '' ) { + $proxy = CT_PROXY_HOST . ':' . CT_PROXY_PORT; + $options['proxy'] = $proxy; // WP HTTP API branch + $options[CURLOPT_PROXY] = CT_PROXY_HOST; // cURL branch + $options[CURLOPT_PROXYPORT] = CT_PROXY_PORT; + } + $http = new Request(); $request = $http->setUrl($url) ->setData($data) ->setPresets(['retry_with_socket']) - ->setOptions(['timeout' => $timeout]); + ->setOptions($options); if ( isset($data['method_name']) ) { $request->addCallback( __CLASS__ . '::checkResponse', From 16ffabf13d64b400e8bd38680e788f0f2307ccc6 Mon Sep 17 00:00:00 2001 From: datorik Date: Mon, 15 Jun 2026 12:22:41 +0300 Subject: [PATCH 28/35] Code. Proxy --- lib/Cleantalk/ApbctWP/API.php | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/lib/Cleantalk/ApbctWP/API.php b/lib/Cleantalk/ApbctWP/API.php index 0c1c3b896..95e6f0750 100644 --- a/lib/Cleantalk/ApbctWP/API.php +++ b/lib/Cleantalk/ApbctWP/API.php @@ -84,12 +84,14 @@ public static function sendRequest($data, $timeout = 10) $data['agent'] = defined('APBCT_AGENT') ? APBCT_AGENT : ''; $options = ['timeout' => $timeout]; - // Optional proxy (constants declared in wp-config.php). - if ( defined('CT_PROXY_HOST') && CT_PROXY_HOST !== '' ) { - $proxy = CT_PROXY_HOST . ':' . CT_PROXY_PORT; - $options['proxy'] = $proxy; // WP HTTP API branch - $options[CURLOPT_PROXY] = CT_PROXY_HOST; // cURL branch - $options[CURLOPT_PROXYPORT] = CT_PROXY_PORT; + // Proxy settings from wp-config.php constants. + if ( defined('WP_PROXY_HOST') && WP_PROXY_HOST !== '' ) { + $options['proxy'] = WP_PROXY_HOST . ':' . WP_PROXY_PORT; // WP HTTP API branch + $options[CURLOPT_PROXY] = WP_PROXY_HOST; // cURL branch + $options[CURLOPT_PROXYPORT] = WP_PROXY_PORT; + if ( defined('WP_PROXY_USERNAME') && WP_PROXY_USERNAME !== '' ) { + $options[CURLOPT_PROXYUSERPWD] = WP_PROXY_USERNAME . ':' . (defined('WP_PROXY_PASSWORD') ? WP_PROXY_PASSWORD : ''); + } } $http = new Request(); From 8269d7e7b48f8a2e2729aa3d5e7947c5aff9f15f Mon Sep 17 00:00:00 2001 From: datorik Date: Mon, 22 Jun 2026 17:03:28 +0300 Subject: [PATCH 29/35] Code. Proxy. Code review --- lib/Cleantalk/ApbctWP/API.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Cleantalk/ApbctWP/API.php b/lib/Cleantalk/ApbctWP/API.php index 95e6f0750..8fff9a508 100644 --- a/lib/Cleantalk/ApbctWP/API.php +++ b/lib/Cleantalk/ApbctWP/API.php @@ -85,7 +85,7 @@ public static function sendRequest($data, $timeout = 10) $options = ['timeout' => $timeout]; // Proxy settings from wp-config.php constants. - if ( defined('WP_PROXY_HOST') && WP_PROXY_HOST !== '' ) { + if ( defined('WP_PROXY_HOST') && WP_PROXY_HOST !== '' && defined('WP_PROXY_PORT') && WP_PROXY_PORT !== '' ) { $options['proxy'] = WP_PROXY_HOST . ':' . WP_PROXY_PORT; // WP HTTP API branch $options[CURLOPT_PROXY] = WP_PROXY_HOST; // cURL branch $options[CURLOPT_PROXYPORT] = WP_PROXY_PORT; From a9fc6a63ce4c7d6902e065e5c4cc9dc3374300ef Mon Sep 17 00:00:00 2001 From: datorik Date: Wed, 1 Jul 2026 13:29:16 +0300 Subject: [PATCH 30/35] Code. Code review --- lib/Cleantalk/ApbctWP/API.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Cleantalk/ApbctWP/API.php b/lib/Cleantalk/ApbctWP/API.php index 8fff9a508..f61f6a049 100644 --- a/lib/Cleantalk/ApbctWP/API.php +++ b/lib/Cleantalk/ApbctWP/API.php @@ -85,7 +85,7 @@ public static function sendRequest($data, $timeout = 10) $options = ['timeout' => $timeout]; // Proxy settings from wp-config.php constants. - if ( defined('WP_PROXY_HOST') && WP_PROXY_HOST !== '' && defined('WP_PROXY_PORT') && WP_PROXY_PORT !== '' ) { + if ( function_exists('curl_init') && defined('WP_PROXY_HOST') && WP_PROXY_HOST !== '' && defined('WP_PROXY_PORT') && WP_PROXY_PORT !== '' ) { $options['proxy'] = WP_PROXY_HOST . ':' . WP_PROXY_PORT; // WP HTTP API branch $options[CURLOPT_PROXY] = WP_PROXY_HOST; // cURL branch $options[CURLOPT_PROXYPORT] = WP_PROXY_PORT; From 8e7cc33f9f175cb0bd1096a2d1d6337a9fdf0e54 Mon Sep 17 00:00:00 2001 From: datorik Date: Wed, 1 Jul 2026 13:37:51 +0300 Subject: [PATCH 31/35] Code. Code review --- lib/Cleantalk/ApbctWP/API.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/Cleantalk/ApbctWP/API.php b/lib/Cleantalk/ApbctWP/API.php index f61f6a049..0b411bd4c 100644 --- a/lib/Cleantalk/ApbctWP/API.php +++ b/lib/Cleantalk/ApbctWP/API.php @@ -85,11 +85,12 @@ public static function sendRequest($data, $timeout = 10) $options = ['timeout' => $timeout]; // Proxy settings from wp-config.php constants. - if ( function_exists('curl_init') && defined('WP_PROXY_HOST') && WP_PROXY_HOST !== '' && defined('WP_PROXY_PORT') && WP_PROXY_PORT !== '' ) { - $options['proxy'] = WP_PROXY_HOST . ':' . WP_PROXY_PORT; // WP HTTP API branch - $options[CURLOPT_PROXY] = WP_PROXY_HOST; // cURL branch + $options['proxy'] = WP_PROXY_HOST . ':' . WP_PROXY_PORT; // WP HTTP API branch + // cURL branch (avoid referencing CURLOPT_* when ext-curl is not available) + if ( function_exists('curl_init') && defined('CURLOPT_PROXY') && defined('CURLOPT_PROXYPORT') ) { + $options[CURLOPT_PROXY] = WP_PROXY_HOST; $options[CURLOPT_PROXYPORT] = WP_PROXY_PORT; - if ( defined('WP_PROXY_USERNAME') && WP_PROXY_USERNAME !== '' ) { + if ( defined('WP_PROXY_USERNAME') && WP_PROXY_USERNAME !== '' && defined('CURLOPT_PROXYUSERPWD') ) { $options[CURLOPT_PROXYUSERPWD] = WP_PROXY_USERNAME . ':' . (defined('WP_PROXY_PASSWORD') ? WP_PROXY_PASSWORD : ''); } } From 62fff6b428416584a163d0c399978aab801d99bc Mon Sep 17 00:00:00 2001 From: datorik Date: Thu, 2 Jul 2026 09:10:25 +0300 Subject: [PATCH 32/35] Code. Code review --- lib/Cleantalk/ApbctWP/API.php | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/lib/Cleantalk/ApbctWP/API.php b/lib/Cleantalk/ApbctWP/API.php index 0b411bd4c..b8797313d 100644 --- a/lib/Cleantalk/ApbctWP/API.php +++ b/lib/Cleantalk/ApbctWP/API.php @@ -85,13 +85,15 @@ public static function sendRequest($data, $timeout = 10) $options = ['timeout' => $timeout]; // Proxy settings from wp-config.php constants. - $options['proxy'] = WP_PROXY_HOST . ':' . WP_PROXY_PORT; // WP HTTP API branch - // cURL branch (avoid referencing CURLOPT_* when ext-curl is not available) - if ( function_exists('curl_init') && defined('CURLOPT_PROXY') && defined('CURLOPT_PROXYPORT') ) { - $options[CURLOPT_PROXY] = WP_PROXY_HOST; - $options[CURLOPT_PROXYPORT] = WP_PROXY_PORT; - if ( defined('WP_PROXY_USERNAME') && WP_PROXY_USERNAME !== '' && defined('CURLOPT_PROXYUSERPWD') ) { - $options[CURLOPT_PROXYUSERPWD] = WP_PROXY_USERNAME . ':' . (defined('WP_PROXY_PASSWORD') ? WP_PROXY_PASSWORD : ''); + if ( defined('WP_PROXY_HOST') && defined('WP_PROXY_PORT') ) { + $options['proxy'] = WP_PROXY_HOST . ':' . WP_PROXY_PORT; // WP HTTP API branch + // cURL branch (avoid referencing CURLOPT_* when ext-curl is not available) + if ( function_exists('curl_init') && defined('CURLOPT_PROXY') && defined('CURLOPT_PROXYPORT') ) { + $options[CURLOPT_PROXY] = WP_PROXY_HOST; + $options[CURLOPT_PROXYPORT] = WP_PROXY_PORT; + if ( defined('WP_PROXY_USERNAME') && WP_PROXY_USERNAME !== '' && defined('CURLOPT_PROXYUSERPWD') ) { + $options[CURLOPT_PROXYUSERPWD] = WP_PROXY_USERNAME . ':' . (defined('WP_PROXY_PASSWORD') ? WP_PROXY_PASSWORD : ''); + } } } From 7daa26ceddf51a4483c03f4ec281554e908e204b Mon Sep 17 00:00:00 2001 From: datorik Date: Thu, 2 Jul 2026 13:58:07 +0300 Subject: [PATCH 33/35] Code. Code review --- lib/Cleantalk/ApbctWP/API.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/Cleantalk/ApbctWP/API.php b/lib/Cleantalk/ApbctWP/API.php index b8797313d..d137a851b 100644 --- a/lib/Cleantalk/ApbctWP/API.php +++ b/lib/Cleantalk/ApbctWP/API.php @@ -85,12 +85,13 @@ public static function sendRequest($data, $timeout = 10) $options = ['timeout' => $timeout]; // Proxy settings from wp-config.php constants. - if ( defined('WP_PROXY_HOST') && defined('WP_PROXY_PORT') ) { - $options['proxy'] = WP_PROXY_HOST . ':' . WP_PROXY_PORT; // WP HTTP API branch + if ( defined('WP_PROXY_HOST') && WP_PROXY_HOST !== '' && defined('WP_PROXY_PORT') && WP_PROXY_PORT !== '' ) { + $proxy_port = (int) WP_PROXY_PORT; + $options['proxy'] = WP_PROXY_HOST . ':' . $proxy_port; // WP HTTP API branch // cURL branch (avoid referencing CURLOPT_* when ext-curl is not available) if ( function_exists('curl_init') && defined('CURLOPT_PROXY') && defined('CURLOPT_PROXYPORT') ) { $options[CURLOPT_PROXY] = WP_PROXY_HOST; - $options[CURLOPT_PROXYPORT] = WP_PROXY_PORT; + $options[CURLOPT_PROXYPORT] = $proxy_port; if ( defined('WP_PROXY_USERNAME') && WP_PROXY_USERNAME !== '' && defined('CURLOPT_PROXYUSERPWD') ) { $options[CURLOPT_PROXYUSERPWD] = WP_PROXY_USERNAME . ':' . (defined('WP_PROXY_PASSWORD') ? WP_PROXY_PASSWORD : ''); } From f0f2472d4f6d5e4eb03c5256ef0349d78739894f Mon Sep 17 00:00:00 2001 From: Glomberg Date: Tue, 7 Jul 2026 10:33:16 +0300 Subject: [PATCH 34/35] Fix. Code. Copilot notice https://github.com/CleanTalk/wordpress-antispam/pull/820#discussion_r3480337348 fixed. --- lib/Cleantalk/ApbctWP/Firewall/SFW.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Cleantalk/ApbctWP/Firewall/SFW.php b/lib/Cleantalk/ApbctWP/Firewall/SFW.php index e11ee42f6..1280701ed 100644 --- a/lib/Cleantalk/ApbctWP/Firewall/SFW.php +++ b/lib/Cleantalk/ApbctWP/Firewall/SFW.php @@ -442,7 +442,7 @@ public function diePage($result) '{SERVICE_ID}' => esc_html($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') . '

', - '{REQUEST_URI}' => esc_html($request_uri), + '{REQUEST_URI}' => esc_url($request_uri), // Cookie '{COOKIE_PREFIX}' => '', From 172042b70ed72b7f54c0864f83f65b3395c57355 Mon Sep 17 00:00:00 2001 From: Glomberg Date: Tue, 7 Jul 2026 10:39:41 +0300 Subject: [PATCH 35/35] Fix. Code. Copilot notice https://github.com/CleanTalk/wordpress-antispam/pull/820#discussion_r3480337502 fixed. --- lib/Cleantalk/ApbctWP/Firewall/AntiCrawler.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/Cleantalk/ApbctWP/Firewall/AntiCrawler.php b/lib/Cleantalk/ApbctWP/Firewall/AntiCrawler.php index c5d71b592..bb7036b91 100644 --- a/lib/Cleantalk/ApbctWP/Firewall/AntiCrawler.php +++ b/lib/Cleantalk/ApbctWP/Firewall/AntiCrawler.php @@ -595,9 +595,9 @@ public function diePage($result) if ( Get::get('debug') ) { $debug = '

Headers

' - . str_replace("\n", "
", print_r(\apache_request_headers(), true)) + . esc_html(str_replace("\n", "
", print_r(\apache_request_headers(), true))) . '

$_SERVER

' - . str_replace("\n", "
", print_r($_SERVER, true)) + . esc_html(str_replace("\n", "
", print_r($_SERVER, true))) . '

AC_LOG_RESULT

' . esc_html(str_replace("\n", "
", print_r($this->ac_log_result, true))) . '

IPS

'