From 4f963c41e34f9f0235fcfa9dff1b2f64cf71bc7d Mon Sep 17 00:00:00 2001 From: alexandergull Date: Tue, 6 Oct 2026 11:24:44 +0500 Subject: [PATCH] Fix. Cleantalk. New curlopt_resolve way implemented, rotateModerate logic refactored. --- inc/cleantalk-common.php | 96 +----- inc/cleantalk-updater.php | 11 + lib/Cleantalk/Antispam/Cleantalk.php | 272 ++++++++++++++-- .../IntegrationsByClass/Woocommerce.php | 7 +- .../ApbctWP/Antispam/ForceProtection.php | 7 +- .../ContactsEncoder/ContactsEncoder.php | 10 +- .../ApbctWP/ModerateServerConfig.php | 168 ++++++++++ lib/Cleantalk/Common/HTTP/Request.php | 19 ++ tests/Antispam/CleantalkDnsOverrideTest.php | 266 ++++++++++++++++ tests/ApbctWP/TestModerateServerConfig.php | 297 ++++++++++++++++++ .../Common/HTTP/TestRequestResolveOption.php | 118 +++++++ .../StandaloneFunctions/TestGetServerUrl.php | 6 +- 12 files changed, 1147 insertions(+), 130 deletions(-) create mode 100644 lib/Cleantalk/ApbctWP/ModerateServerConfig.php create mode 100644 tests/Antispam/CleantalkDnsOverrideTest.php create mode 100644 tests/ApbctWP/TestModerateServerConfig.php create mode 100644 tests/Common/HTTP/TestRequestResolveOption.php diff --git a/inc/cleantalk-common.php b/inc/cleantalk-common.php index 5967935cb..54c8957d9 100644 --- a/inc/cleantalk-common.php +++ b/inc/cleantalk-common.php @@ -8,16 +8,15 @@ use Cleantalk\ApbctWP\BotDetectorService; use Cleantalk\ApbctWP\CleantalkSettingsTemplates; use Cleantalk\ApbctWP\Constant; -use Cleantalk\ApbctWP\Cron; use Cleantalk\ApbctWP\DB; use Cleantalk\ApbctWP\DTO\GetFieldsAnyDTO; use Cleantalk\ApbctWP\Firewall\SFW; use Cleantalk\ApbctWP\GetFieldsAny; use Cleantalk\ApbctWP\Helper; use Cleantalk\ApbctWP\Honeypot; +use Cleantalk\ApbctWP\ModerateServerConfig; use Cleantalk\ApbctWP\RequestParameters\RequestParameters; use Cleantalk\ApbctWP\RequestParameters\SubmitTimeHandler; -use Cleantalk\ApbctWP\Sanitize; use Cleantalk\ApbctWP\Variables\AltSessions; use Cleantalk\ApbctWP\Variables\Cookie; use Cleantalk\ApbctWP\Variables\Get; @@ -260,11 +259,7 @@ function apbct_base_call($params = array(), $reg_flag = false) ); // Options store url without scheme because of DB error with ''://' - $config = ct_get_server(); - $ct->server_url = APBCT_MODERATE_URL; - $ct->work_url = isset($config['ct_work_url']) && preg_match('/https:\/\/.+/', $config['ct_work_url']) ? $config['ct_work_url'] : null; - $ct->server_ttl = isset($config['ct_server_ttl']) ? $config['ct_server_ttl'] : null; - $ct->server_changed = isset($config['ct_server_changed']) ? $config['ct_server_changed'] : null; + ModerateServerConfig::set($ct); $start = microtime(true); $ct_result = $reg_flag @@ -280,18 +275,7 @@ function apbct_base_call($params = array(), $reg_flag = false) $apbct->stats['last_request']['server'] = $ct->work_url; $apbct->save('stats'); - if ( $ct->server_change ) { - update_option( - 'cleantalk_server', - array( - 'ct_work_url' => $ct->work_url, - 'ct_server_ttl' => $ct->server_ttl, - 'ct_server_changed' => time(), - ) - ); - $cron = new Cron(); - $cron->updateTask('rotate_moderate', 'apbct_rotate_moderate', 86400); // Rotate moderate server - } + ModerateServerConfig::save($ct); //alternative checks and connection report handler if ($ct_result instanceof \Cleantalk\Antispam\CleantalkResponse) { @@ -320,21 +304,21 @@ function apbct_base_call($params = array(), $reg_flag = false) return array('ct' => $ct, 'ct_result' => $ct_result); } +/** + * Daily moderate rotation. + * + * Always uses the plain hostname strategy, so it doubles as the recovery point: + * rotateModerate() clears any pinned IP and the fresh record is stored with + * 'ct_resolve_ip' => null. From the next request on, traffic goes out with normal + * name resolution and no CURLOPT_RESOLVE at all. + */ function apbct_rotate_moderate() { $ct = new Cleantalk(); $ct->server_url = APBCT_MODERATE_URL; $ct->rotateModerate(); - if ( $ct->server_change ) { - update_option( - 'cleantalk_server', - array( - 'ct_work_url' => $ct->work_url, - 'ct_server_ttl' => $ct->server_ttl, - 'ct_server_changed' => time(), - ) - ); - } + // No cron re-arming: this IS the cron callback. + ModerateServerConfig::save($ct, false); } function apbct_exclusions_check($func = null) @@ -962,40 +946,6 @@ function ct_get_admin_email() return $admin_email; } -/** - * Inner function - Current CleanTalk working server info - * @return array Array of server data - */ -function ct_get_server() -{ - $ct_server = get_option('cleantalk_server'); - if ( ! is_array($ct_server) ) { - $ct_server = array( - 'ct_work_url' => null, - 'ct_server_ttl' => null, - 'ct_server_changed' => null - ); - } - - $ct_server['ct_work_url'] = Sanitize::sanitizeCleantalkServerUrl(TT::getArrayValueAsString($ct_server, 'ct_work_url')); - - return $ct_server; -} - -/** - * @param $url - * - * @return string|null - */ -function sanitize_cleantalk_server_url($url) -{ - if (!is_string($url)) { - return null; - } - return preg_match('/^.*(moderate|api).*\.cleantalk.org(?!\.)[\/\\\\]{0,1}/m', $url) - ? $url - : null; -} /** * Inner function - Stores ang returns cleantalk hash of current comment * @@ -1075,11 +1025,7 @@ function ct_send_feedback($feedback_request = null) $ct = new Cleantalk(); // Server URL handling - $config = ct_get_server(); - $ct->server_url = APBCT_MODERATE_URL; - $ct->work_url = isset($config['ct_work_url']) && preg_match('/http:\/\/.+/', $config['ct_work_url']) ? $config['ct_work_url'] : null; - $ct->server_ttl = isset($config['ct_server_ttl']) ? $config['ct_server_ttl'] : null; - $ct->server_changed = isset($config['ct_server_changed']) ? $config['ct_server_changed'] : null; + ModerateServerConfig::set($ct); //method use api3.0 since 6.35(6.36?) $ct->api_version = '/api3.0'; @@ -1087,18 +1033,8 @@ function ct_send_feedback($feedback_request = null) $ct_result = $ct->sendFeedback($ct_request); - if ( $ct->server_change ) { - update_option( - 'cleantalk_server', - array( - 'ct_work_url' => $ct->work_url, - 'ct_server_ttl' => $ct->server_ttl, - 'ct_server_changed' => time(), - ) - ); - $cron = new Cron(); - $cron->updateTask('rotate_moderate', 'apbct_rotate_moderate', 86400); // Rotate moderate server - } + ModerateServerConfig::save($ct); + if ( $ct_result ) { return true; } diff --git a/inc/cleantalk-updater.php b/inc/cleantalk-updater.php index 20ef1737b..37ddce244 100644 --- a/inc/cleantalk-updater.php +++ b/inc/cleantalk-updater.php @@ -2,6 +2,7 @@ use Cleantalk\ApbctWP\Cron; use Cleantalk\ApbctWP\Helper; +use Cleantalk\ApbctWP\ModerateServerConfig; use Cleantalk\ApbctWP\Variables\Server; use Cleantalk\ApbctWP\Firewall\SFWUpdateHelper; use Cleantalk\Common\TT; @@ -1404,3 +1405,13 @@ function apbct_update_to_6_89_0() $apbct->saveSettings(); } } + +/** + * Drop the Moderate Server Config cache to ensure that the plugin fetches the latest configuration from the server after the update. + * + * @return void + */ +function apbct_update_to_6_90_0() +{ + ModerateServerConfig::reset(); +} diff --git a/lib/Cleantalk/Antispam/Cleantalk.php b/lib/Cleantalk/Antispam/Cleantalk.php index 097ca0f7c..3540af65d 100644 --- a/lib/Cleantalk/Antispam/Cleantalk.php +++ b/lib/Cleantalk/Antispam/Cleantalk.php @@ -5,6 +5,7 @@ use Cleantalk\ApbctWP\Helper; use Cleantalk\ApbctWP\HTTP\Request; use Cleantalk\Common\DNS; +use Cleantalk\Common\TT; /** * Cleantalk base class @@ -45,29 +46,67 @@ class Cleantalk public $server_url; /** - * Last work url - * @var string + * Last work url. + * + * NULL means "no usable cached server", which makes httpRequest() start with + * a rotation instead of reusing the previous target. + * + * @var string|null */ public $work_url; /** * Work url ttl - * @var int + * @var int|null */ public $server_ttl; /** * Time work_url changed - * @var int + * @var int|null */ public $server_changed; /** - * Flag is change server url + * A server was CONFIRMED working: a rotation happened and the retry on the new + * target came back with errno 0. + * + * This is the only state worth caching. Set exclusively by httpRequest() after a + * successful response -- never by the rotation methods, which cannot know yet + * whether the server they picked actually answers. + * * @var bool */ public $server_change = false; + /** + * The in-memory server state no longer matches the stored record, but nothing + * has been confirmed working. + * + * Set by the rotation methods whenever they touch $work_url or $dns_resolve_ip. + * Keeping the two flags apart is what stops a dead server from being cached for + * a day, and what stops a stale pinned IP from outliving the rotation that + * cleared it. See ModerateServerConfig::save() for how each flag is handled. + * + * @var bool + */ + public $server_state_dirty = false; + + /** + * IP address the moderate hostname must be pinned to via CURLOPT_RESOLVE. + * + * Set ONLY by rotateModerateWithDnsOverride(), i.e. only when cURL's own + * resolver is broken ('getaddrinfo() thread failed to start'). The hostname + * stays in $work_url, so the '*.cleantalk.org' certificate still validates -- + * never put this IP into the URL itself. + * + * NULL means "resolve normally", which is the default and the state we fall + * back to on every rotation. + * + * @var string|null + */ + public $dns_resolve_ip; + /** * Codepage of the data * @var string @@ -260,19 +299,33 @@ public function httpRequest($msg) // Getting type of error $type_error = $this->getTypeError($result); - $failed_urls = $this->work_url; + $failed_urls = $this->describeCurrentTarget(); if ( ! empty($this->work_url) ) { - $this->downServers[] = $this->work_url; + $this->downServers[] = $this->getCurrentTargetKey(); } - if ( ($type_error === 'getaddrinfo_error' || $type_error === 'connection_timeout') && $attempt === 1 ) { - $this->rotateModerateAndUseIP(); - //exit if next sendRequest failed, because change dns->ip is only way to fix errors above - $attempt = $attempt + 2; + // Pick a recovery strategy that actually matches the failure. + // + // 'getaddrinfo_error' means cURL itself cannot resolve ANY hostname, so + // moving to another moderate host would hit the very same wall. The only + // cure is to feed cURL a ready IP while keeping the hostname in the URL. + // + // Everything else ('connection_timeout', 'unknown') is a per-server + // problem: plain rotation to another moderate host fixes it and needs no + // IP pinning at all. + if ( $type_error === 'getaddrinfo_error' ) { + $this->rotateModerateWithDnsOverride(); } else { $this->rotateModerate(); - //try change server again if next sendRequest failed - $attempt = $attempt + 1; + } + $attempt++; + + // Rotation can legitimately come back empty (no DNS records, every + // candidate already marked as down). There is no URL left to try, so + // stop instead of calling sendRequest() with a null URL. + if ( empty($this->work_url) ) { + $result = false; + break; } $result = $this->sendRequest($msg, $this->work_url, $this->server_timeout); @@ -282,7 +335,7 @@ public function httpRequest($msg) break; } - $failed_urls .= ', ' . $this->work_url; + $failed_urls .= ', ' . $this->describeCurrentTarget(); } /** @psalm-suppress PossiblyInvalidArgument */ $response = new CleantalkResponse($result, $failed_urls); @@ -303,7 +356,34 @@ public function httpRequest($msg) } /** - * * @todo Refactor / fix logic errors + * Plain moderate rotation: move to another moderate host by its hostname. + * + * FIRES ON: 'connection_timeout' (cURL error 28) and 'unknown' errors, i.e. + * when DNS works fine but the current moderate server is slow or down. + * + * HOW: resolves 'moderate.cleantalk.org' to its A records, then maps each IP + * back to a verified PTR hostname ('moderate3.cleantalk.org') and uses THAT + * hostname in $work_url. The hostname is covered by the '*.cleantalk.org' + * certificate, so TLS verification keeps working. + * + * DOES NOT set $dns_resolve_ip -- it explicitly clears it and raises + * $server_state_dirty, so a previously pinned IP never outlives a rotation even + * if no replacement server is found. The one exception is an early return on a + * dead resolver (getServersIp() empty): there is nothing to rotate to, and the + * pin is then the only remaining way to reach a server, so it is left alone and + * expires through the TTL instead. + * + * DOES NOT set $server_change: picking a candidate is not proof it answers. + * Only httpRequest() raises that flag, after a successful retry. + * + * REQUIRES: a working PHP-level resolver (dns_get_record / gethostbyaddr). + * It cannot repair 'getaddrinfo_error', because cURL would still have to + * resolve the new hostname through the very same broken resolver -- + * see rotateModerateWithDnsOverride() for that case. + * + * @return void + * + * @todo Refactor / fix logic errors */ public function rotateModerate() { @@ -320,6 +400,12 @@ public function rotateModerate() return; } + // Back to normal name resolution: this strategy never pins an IP. + // Dropping the pin is itself a state change that must reach the option, + // otherwise a stale IP would be restored on the next request. + $this->dns_resolve_ip = null; + $this->server_state_dirty = true; + // Loop until find work server foreach ( $servers as $server ) { $dns = Helper::ipResolve($server['ip']); @@ -334,19 +420,56 @@ public function rotateModerate() continue; } - $this->server_ttl = $server['ttl']; - $this->server_change = true; + $this->server_ttl = $server['ttl']; break; } } /** - * * @todo Refactor / fix logic errors + * Moderate rotation with a DNS override: keep the hostname, pin the IP. + * + * FIRES ON: 'getaddrinfo_error' ONLY ('getaddrinfo() thread failed to start'), + * i.e. when cURL's own resolver is broken while PHP's resolver still answers. + * Plain rotation cannot help there, because any new hostname would be resolved + * through the same broken path. + * + * HOW: $work_url keeps the ORIGINAL hostname ('https://moderate.cleantalk.org') + * and the chosen IP goes into $dns_resolve_ip, which sendRequest() turns into a + * CURLOPT_RESOLVE entry. cURL then skips getaddrinfo() but still presents the + * hostname for SNI and certificate verification. + * + * WHY NOT AN IP IN THE URL: the CleanTalk certificate only carries + * 'CN=*.cleantalk.org' / 'SAN: *.cleantalk.org, cleantalk.org' and no IP SANs, + * so 'https://88.198.153.60/' fails with cURL error 60. $work_url is also reused + * for the browser-facing pixel URL, which would expose that error to visitors. + * + * SIDE EFFECT: none here. The cURL transport is forced by sendRequest(), which + * is the only place that knows a resolve override is actually being applied -- + * including on later requests that restore the pinned IP from the option. + * + * FALLBACK: if cURL or CURLOPT_RESOLVE is unavailable (cURL < 7.21.3), delegates + * to rotateModerate(). + * + * RECOVERY: the pinned IP lives in the 'cleantalk_server' option next to + * $work_url and dies with it -- on TTL expiry, on the daily rotate_moderate cron, + * or on any later rotateModerate(), which clears the pin and marks the state + * dirty so the clearing actually reaches the option even when the rotation + * itself finds nothing usable. + * + * @return void + * + * @todo Refactor / fix logic errors */ - public function rotateModerateAndUseIP() + public function rotateModerateWithDnsOverride() { + // No cURL, no CURLOPT_RESOLVE - nothing to override with. + if ( ! function_exists('curl_init') || ! defined('CURLOPT_RESOLVE') ) { + $this->rotateModerate(); + + return; + } + // Split server url to parts - global $apbct; preg_match("/^(https?:\/\/)([^\/:]+)(.*)/i", $this->server_url, $matches); $url_protocol = isset($matches[1]) ? $matches[1] : ''; @@ -359,28 +482,85 @@ public function rotateModerateAndUseIP() return; } - $apbct->settings['wp__use_builtin_http_api'] = false; - // Loop until find work server foreach ( $servers as $server ) { - $dns = Helper::ipResolve($server['ip']); - if ( ! $dns ) { + if ( empty($server['ip']) ) { continue; } - $this->work_url = $url_protocol . $server['ip'] . $url_suffix; + // Hostname stays intact - only the resolution step is overridden. + $this->work_url = $url_protocol . $url_host . $url_suffix; + $this->dns_resolve_ip = $server['ip']; + $this->server_state_dirty = true; // Do not checking previous down server - if ( ! empty($this->downServers) && in_array($this->work_url, $this->downServers) ) { + if ( ! empty($this->downServers) && in_array($this->getCurrentTargetKey(), $this->downServers) ) { continue; } - $this->server_ttl = $server['ttl']; - $this->server_change = true; + $this->server_ttl = $server['ttl']; break; } } + /** + * Identifier of the current target used to mark it as down. + * + * With a DNS override $work_url is identical for every candidate, so the pinned + * IP has to be part of the key -- otherwise the first failed IP would blacklist + * all the others. + * + * @return string + */ + private function getCurrentTargetKey() + { + return empty($this->dns_resolve_ip) + ? (string)$this->work_url + : $this->work_url . '#' . $this->dns_resolve_ip; + } + + /** + * Human readable description of the current target for connection reports. + * + * @return string + */ + private function describeCurrentTarget() + { + return empty($this->dns_resolve_ip) + ? (string)$this->work_url + : $this->work_url . ' (resolved to ' . $this->dns_resolve_ip . ')'; + } + + /** + * Build the 'resolve' option (CURLOPT_RESOLVE format) for the given URL. + * + * Returns NULL when no override is active, which is the normal case -- the + * request then goes out with ordinary name resolution and no cURL-specific + * options at all. + * + * @param string $url + * + * @return array|null array('host:port:ip') or null + */ + private function buildResolveOption($url) + { + if ( empty($this->dns_resolve_ip) || ! is_string($url) ) { + return null; + } + + $parsed = parse_url($url); + + if ( empty($parsed['host']) ) { + return null; + } + + $port = isset($parsed['port']) + ? TT::getArrayValueAsString($parsed, 'port') + : (isset($parsed['scheme']) && $parsed['scheme'] === 'http' ? '80' : '443'); + + return array($parsed['host'] . ':' . $port . ':' . $this->dns_resolve_ip); + } + /** * @param $host * @return array|null @@ -516,6 +696,8 @@ public function httpPing($host) */ private function sendRequest($data, $url, $server_timeout = 3) { + global $apbct; + //Cleaning from 'null' values $tmp_data = array(); /** @psalm-suppress PossiblyInvalidIterator */ @@ -552,10 +734,27 @@ private function sendRequest($data, $url, $server_timeout = 3) ? $url . '/' . $this->method_uri : $url; + $options = ['timeout' => $server_timeout]; + + // Only present while a DNS override is active (getaddrinfo_error recovery). + // Without it the request goes out with plain name resolution. + $resolve = $this->buildResolveOption($url); + if ( $resolve !== null ) { + $options['resolve'] = $resolve; + + // CURLOPT_RESOLVE exists only in the cURL transport. The WP HTTP API may + // pick fsockopen/streams instead and would silently drop the override, + // sending the request through the very resolver we are working around. + // Forced here rather than at rotation time because $dns_resolve_ip is + // persisted: every later request restores it from the option and must + // force cURL again. + $apbct->settings['wp__use_builtin_http_api'] = false; + } + $result = $http->setUrl($url) ->setData($data) ->setPresets($presets) - ->setOptions(['timeout' => $server_timeout]) + ->setOptions($options) ->request(); $errstr = null; @@ -601,6 +800,21 @@ public function checkBot(CleantalkRequest $request) return $this->httpRequest($msg); } + /** + * Classify a failed request so httpRequest() can pick a recovery strategy. + * + * 'connection_timeout' - cURL error 28. DNS is fine, this particular moderate + * server is slow/down => rotateModerate(). + * 'getaddrinfo_error' - cURL cannot resolve anything at all. Rotating to + * another hostname changes nothing => + * rotateModerateWithDnsOverride(). + * 'unknown' - anything else, treated as a per-server problem => + * rotateModerate(). + * + * @param mixed $result + * + * @return string + */ private function getTypeError($result) { if (isset($result->errstr)) { diff --git a/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php b/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php index f509dc662..07c9ddd9f 100644 --- a/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php +++ b/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php @@ -6,6 +6,7 @@ use Cleantalk\Antispam\CleantalkRequest; use Cleantalk\Antispam\IntegrationMetrics\IMetricDTO; use Cleantalk\Antispam\IntegrationMetrics\IMetricService; +use Cleantalk\ApbctWP\ModerateServerConfig; use Cleantalk\ApbctWP\Sanitize; use Cleantalk\ApbctWP\Variables\Cookie; use Cleantalk\ApbctWP\Variables\Get; @@ -1453,11 +1454,7 @@ public function ordersSendFeedback(array $spam_ids, $orders_status = '0') $ct = new Cleantalk(); // Server URL handling - $config = ct_get_server(); - $ct->server_url = APBCT_MODERATE_URL; - $ct->work_url = isset($config['ct_work_url']) && preg_match('/http:\/\/.+/', $config['ct_work_url']) ? $config['ct_work_url'] : null; - $ct->server_ttl = isset($config['ct_server_ttl']) ? $config['ct_server_ttl'] : null; - $ct->server_changed = isset($config['ct_server_changed']) ? $config['ct_server_changed'] : null; + ModerateServerConfig::set($ct); $ct->sendFeedback($ct_request); } catch (\Exception $e) { diff --git a/lib/Cleantalk/ApbctWP/Antispam/ForceProtection.php b/lib/Cleantalk/ApbctWP/Antispam/ForceProtection.php index 3ec9ab70b..c7a9e43f3 100644 --- a/lib/Cleantalk/ApbctWP/Antispam/ForceProtection.php +++ b/lib/Cleantalk/ApbctWP/Antispam/ForceProtection.php @@ -3,6 +3,7 @@ namespace Cleantalk\ApbctWP\Antispam; use Cleantalk\ApbctWP\Helper; +use Cleantalk\ApbctWP\ModerateServerConfig; use Cleantalk\ApbctWP\RequestParameters\RequestParameters; use Cleantalk\Variables\Post; use Cleantalk\Antispam\CleantalkRequest; @@ -85,11 +86,7 @@ public function checkBot() $ct_request = new CleantalkRequest($params); $ct = new Cleantalk(); - $config = ct_get_server(); - $ct->server_url = APBCT_MODERATE_URL; - $ct->work_url = isset($config['ct_work_url']) && preg_match('/https:\/\/.+/', $config['ct_work_url']) ? $config['ct_work_url'] : null; - $ct->server_ttl = isset($config['ct_server_ttl']) ? $config['ct_server_ttl'] : null; - $ct->server_changed = isset($config['ct_server_changed']) ? $config['ct_server_changed'] : null; + ModerateServerConfig::set($ct); $api_response = $ct->checkBot($ct_request); // Allow to see forms if error occurred diff --git a/lib/Cleantalk/ApbctWP/ContactsEncoder/ContactsEncoder.php b/lib/Cleantalk/ApbctWP/ContactsEncoder/ContactsEncoder.php index 3cceeda4a..a9d56bd39 100644 --- a/lib/Cleantalk/ApbctWP/ContactsEncoder/ContactsEncoder.php +++ b/lib/Cleantalk/ApbctWP/ContactsEncoder/ContactsEncoder.php @@ -8,6 +8,7 @@ use Cleantalk\ApbctWP\ContactsEncoder\Shortcodes\ShortCodesService; use Cleantalk\ApbctWP\Escape; use Cleantalk\ApbctWP\Helper; +use Cleantalk\ApbctWP\ModerateServerConfig; use Cleantalk\Common\ContactsEncoder\Dto\Params; use Cleantalk\ApbctWP\ContactsEncoder\Exclusions\ExclusionsService; use Cleantalk\ApbctWP\ContactsEncoder\Integrations\CEIntegrationGridBuilder; @@ -378,14 +379,7 @@ protected function checkRequest() $this->has_connection_error = false; // Options store url without scheme because of DB error with ''://' - $config = ct_get_server(); - $ct->server_url = APBCT_MODERATE_URL; - $config_work_url = TT::getArrayValueAsString($config, 'ct_work_url'); - $ct->work_url = preg_match('/https:\/\/.+/', $config_work_url) - ? $config_work_url - : ''; - $ct->server_ttl = TT::getArrayValueAsInt($config, 'ct_server_ttl'); - $ct->server_changed = TT::getArrayValueAsInt($config, 'ct_server_changed'); + ModerateServerConfig::set($ct); $api_response = $ct->checkBot($ct_request); // Allow to see to the decoded contact if error occurred diff --git a/lib/Cleantalk/ApbctWP/ModerateServerConfig.php b/lib/Cleantalk/ApbctWP/ModerateServerConfig.php new file mode 100644 index 000000000..8d2ae067c --- /dev/null +++ b/lib/Cleantalk/ApbctWP/ModerateServerConfig.php @@ -0,0 +1,168 @@ +server_url = APBCT_MODERATE_URL; + $ct->work_url = is_string($work_url) && preg_match('/^https:\/\/.+/', $work_url) + ? $work_url + : null; + $ct->server_ttl = $config['ct_server_ttl'] ?? null; + $ct->server_changed = $config['ct_server_changed'] ?? null; + $ct->dns_resolve_ip = $config['ct_resolve_ip'] ?? null; + } + + /** + * Persist the state back after a call. + * + * Three outcomes, driven by the two flags the client raises: + * + * - $server_change: a rotation happened AND the retry succeeded. Only this + * state is worth caching, so it is written and the cache window reopens. + * + * - $server_state_dirty alone: a rotation happened but nothing answered. The + * candidate is unverified, possibly dead, and caching it would pin every + * request for the next day onto a server already known to fail. The record is + * cleared instead, so the next call starts from a clean rotation. Clearing + * also guarantees a pinned 'ct_resolve_ip' cannot outlive the rotation that + * dropped it. + * + * - neither: the cached server answered on the first try. Nothing changed, so + * no write happens and the TTL keeps running. + * + * @param Cleantalk $ct + * @param bool $schedule_rotation Re-arm the daily rotation cron. Disabled when + * called from the rotation cron itself. + * + * @return void + */ + public static function save(Cleantalk $ct, $schedule_rotation = true) + { + if ( ! $ct->server_change ) { + // Rotated into a dead end: drop the cache rather than store a guess. + if ( $ct->server_state_dirty ) { + self::reset(); + } + + return; + } + + update_option( + self::OPTION_NAME, + array( + 'ct_work_url' => $ct->work_url, + 'ct_server_ttl' => $ct->server_ttl, + 'ct_server_changed' => time(), + 'ct_resolve_ip' => $ct->dns_resolve_ip, + ) + ); + + if ( $schedule_rotation ) { + $cron = new Cron(); + $cron->updateTask('rotate_moderate', 'apbct_rotate_moderate', self::ROTATION_PERIOD); + } + } + + /** + * Drop the cached state, forcing a fresh rotation on the next call. + * + * @return void + */ + public static function reset() + { + update_option( + self::OPTION_NAME, + array( + 'ct_work_url' => null, + 'ct_server_ttl' => 0, + 'ct_server_changed' => 0, + 'ct_resolve_ip' => null, + ) + ); + } + + /** + * Read the raw cached record, normalising the two fields that must never be + * trusted blindly. + * + * 'ct_work_url' is passed through Sanitize::sanitizeCleantalkServerUrl(), so only + * genuine moderate/api cleantalk.org hosts survive -- this is what rejects a bare + * IP left over in the DB by older versions. 'ct_resolve_ip' must be a valid IP and + * is dropped whenever the work URL itself was rejected, so a stale pin can never + * outlive the record it belongs to. + * + * Scheme is NOT checked here: legacy records were stored without one. The https + * requirement lives in set(), where the value is actually used. + * + * @return array + */ + public static function getCurrentConfig() + { + $ct_server = get_option(self::OPTION_NAME); + if ( ! is_array($ct_server) ) { + $ct_server = array( + 'ct_work_url' => null, + 'ct_server_ttl' => null, + 'ct_server_changed' => null, + 'ct_resolve_ip' => null + ); + } + + $ct_server['ct_work_url'] = Sanitize::sanitizeCleantalkServerUrl(TT::getArrayValueAsString($ct_server, 'ct_work_url')); + + // Pinned IP for CURLOPT_RESOLVE. Dropped together with the work URL, so a stale + // override can never survive a rotation or an invalidated server record. + $resolve_ip = TT::getArrayValueAsString($ct_server, 'ct_resolve_ip'); + $ct_server['ct_resolve_ip'] = $ct_server['ct_work_url'] && filter_var($resolve_ip, FILTER_VALIDATE_IP) + ? $resolve_ip + : null; + + return $ct_server; + } +} diff --git a/lib/Cleantalk/Common/HTTP/Request.php b/lib/Cleantalk/Common/HTTP/Request.php index 907c7e43c..c225056bc 100644 --- a/lib/Cleantalk/Common/HTTP/Request.php +++ b/lib/Cleantalk/Common/HTTP/Request.php @@ -56,6 +56,15 @@ class Request * CURLOPT_LOW_SPEED_TIME => 10, * CURLOPT_RETURNTRANSFER => true, * ) + * + * Besides raw CURLOPT_* constants the following readable aliases are supported + * and translated by Request::convertOptionsTocURLFormat(): + * timeout - int, seconds + * sslverify - bool, toggles peer and host verification at once + * sslcertificates - string, path to the CA bundle + * resolve - string[], 'host:port:ip' entries for CURLOPT_RESOLVE. + * Use it to bypass a broken system resolver WITHOUT putting + * a bare IP into the URL, which would break TLS verification. */ protected $options = []; @@ -446,6 +455,16 @@ private function convertOptionsTocURLFormat() $temp_options[CURLOPT_CAINFO] = $option_name; // String unset($this->options[$option_name]); break; + // Pre-seed cURL's DNS cache: the URL keeps its hostname (so SNI and + // certificate verification still succeed), but cURL skips getaddrinfo() + // and connects straight to the supplied IP. + // Expected value: array('host:port:ip', ...) + case 'resolve': + if ( defined('CURLOPT_RESOLVE') && is_array($option_value) && ! empty($option_value) ) { + $temp_options[CURLOPT_RESOLVE] = $option_value; // String[] + } + unset($this->options[$option_name]); + break; case 'headers': $temp_options[CURLOPT_HTTPHEADER] = $option_name; // String[] unset($this->options[$option_name]); diff --git a/tests/Antispam/CleantalkDnsOverrideTest.php b/tests/Antispam/CleantalkDnsOverrideTest.php new file mode 100644 index 000000000..94edd72d2 --- /dev/null +++ b/tests/Antispam/CleantalkDnsOverrideTest.php @@ -0,0 +1,266 @@ +setAccessible(true); + + return $ref->invokeArgs($ct, $args); + } + + public function testNoResolveOptionWithoutOverride() + { + $ct = new Cleantalk(); + + $this->assertNull($ct->dns_resolve_ip, 'Override must be off by default'); + $this->assertNull( + $this->callPrivate($ct, 'buildResolveOption', ['https://moderate.cleantalk.org/api2.0']), + 'Without a pinned IP the request must go out with plain name resolution' + ); + } + + public function testResolveEntryUsesImplicitHttpsPort() + { + $ct = new Cleantalk(); + $ct->dns_resolve_ip = '88.198.153.60'; + + $this->assertSame( + ['moderate.cleantalk.org:443:88.198.153.60'], + $this->callPrivate($ct, 'buildResolveOption', ['https://moderate.cleantalk.org/api2.0']) + ); + } + + public function testResolveEntryUsesImplicitHttpPort() + { + $ct = new Cleantalk(); + $ct->dns_resolve_ip = '88.198.153.60'; + + $this->assertSame( + ['moderate.cleantalk.org:80:88.198.153.60'], + $this->callPrivate($ct, 'buildResolveOption', ['http://moderate.cleantalk.org/api2.0']) + ); + } + + public function testResolveEntryKeepsExplicitPort() + { + $ct = new Cleantalk(); + $ct->dns_resolve_ip = '88.198.153.60'; + + $this->assertSame( + ['moderate.cleantalk.org:8443:88.198.153.60'], + $this->callPrivate($ct, 'buildResolveOption', ['https://moderate.cleantalk.org:8443/api2.0']) + ); + } + + public function testResolveEntryIsNullForUrlWithoutHost() + { + $ct = new Cleantalk(); + $ct->dns_resolve_ip = '88.198.153.60'; + + $this->assertNull( + $this->callPrivate($ct, 'buildResolveOption', ['moderate.cleantalk.org/api2.0']) + ); + } + + /** + * Candidates share one URL under an override, so the pinned IP has to be part of + * the down-server key. Otherwise the first failed IP would blacklist all the rest. + */ + public function testDownServerKeyIncludesPinnedIp() + { + $ct = new Cleantalk(); + $ct->work_url = 'https://moderate.cleantalk.org'; + + $this->assertSame( + 'https://moderate.cleantalk.org', + $this->callPrivate($ct, 'getCurrentTargetKey') + ); + + $ct->dns_resolve_ip = '88.198.153.60'; + $first = $this->callPrivate($ct, 'getCurrentTargetKey'); + + $ct->dns_resolve_ip = '159.69.51.30'; + $second = $this->callPrivate($ct, 'getCurrentTargetKey'); + + $this->assertNotSame($first, $second, 'Each pinned IP must be a distinct target'); + } + + public function testTargetDescriptionMentionsPinnedIp() + { + $ct = new Cleantalk(); + $ct->work_url = 'https://moderate.cleantalk.org'; + + $this->assertSame( + 'https://moderate.cleantalk.org', + $this->callPrivate($ct, 'describeCurrentTarget') + ); + + $ct->dns_resolve_ip = '88.198.153.60'; + + $this->assertStringContainsString( + '88.198.153.60', + $this->callPrivate($ct, 'describeCurrentTarget'), + 'Connection reports must show which IP was actually used' + ); + } + + /** + * Error classification drives the recovery strategy, so the mapping is load-bearing: + * only 'getaddrinfo_error' may trigger the DNS override. + */ + public function testGetTypeErrorClassifiesGetaddrinfoFailure() + { + $ct = new Cleantalk(); + $result = (object)['errstr' => 'cURL error 6: getaddrinfo() thread failed to start']; + + $this->assertSame('getaddrinfo_error', $this->callPrivate($ct, 'getTypeError', [$result])); + } + + public function testGetTypeErrorClassifiesTimeout() + { + $ct = new Cleantalk(); + $result = (object)['errstr' => 'cURL error 28: Operation timed out after 15000 milliseconds']; + + $this->assertSame('connection_timeout', $this->callPrivate($ct, 'getTypeError', [$result])); + } + + public function testGetTypeErrorFallsBackToUnknown() + { + $ct = new Cleantalk(); + + $this->assertSame( + 'unknown', + $this->callPrivate($ct, 'getTypeError', [(object)['errstr' => 'cURL error 60: SSL certificate problem']]) + ); + $this->assertSame('unknown', $this->callPrivate($ct, 'getTypeError', [false])); + } + + /** + * Rotation must never claim success on its own: it only picks a candidate, and + * whether that candidate answers is decided later by httpRequest(). An + * unresolvable host is used so the method runs its whole body and still ends + * without a pick. + */ + public function testRotationMarksStateDirtyWithoutClaimingSuccess() + { + $ct = new Cleantalk(); + $ct->server_url = 'https://moderate.cleantalk.org'; + + if ( empty($ct->getServersIp('moderate.cleantalk.org')) ) { + $this->markTestSkipped('No DNS in this environment, rotation cannot run'); + } + + $ct->dns_resolve_ip = '88.198.153.60'; + + $ct->rotateModerate(); + + $this->assertNull($ct->dns_resolve_ip, 'Rotation must drop any pinned IP'); + $this->assertTrue($ct->server_state_dirty, 'The dropped pin has to reach the option'); + $this->assertFalse( + $ct->server_change, + 'Only a confirmed response may mark the server as cacheable' + ); + } + + /** + * Same contract for the DNS override path: pinning an IP is a state change, not + * a confirmation. Without this the dead server would be cached for a full day. + */ + public function testDnsOverrideMarksStateDirtyWithoutClaimingSuccess() + { + $ct = new Cleantalk(); + $ct->server_url = 'https://moderate.cleantalk.org'; + + if ( empty($ct->getServersIp('moderate.cleantalk.org')) ) { + $this->markTestSkipped('No DNS in this environment, rotation cannot run'); + } + + $ct->rotateModerateWithDnsOverride(); + + $this->assertTrue($ct->server_state_dirty, 'Pinning an IP must reach the option'); + $this->assertFalse( + $ct->server_change, + 'Only a confirmed response may mark the server as cacheable' + ); + + if ( defined('CURLOPT_RESOLVE') ) { + $this->assertNotEmpty($ct->dns_resolve_ip, 'The override must actually pin an IP'); + $this->assertStringNotContainsString( + (string)$ct->dns_resolve_ip, + (string)$ct->work_url, + 'The IP must never leak into the URL, it breaks certificate verification' + ); + } + } + + /** + * A refused port, so sendRequest() fails immediately instead of waiting for a + * timeout. Only the transport decision is under test here, not the response. + */ + const DEAD_URL = 'https://127.0.0.1:1'; + + /** + * The pinned IP is persisted, so it comes back on every later request without + * any rotation happening. CURLOPT_RESOLVE exists only in the cURL transport -- + * if the WP HTTP API picked fsockopen/streams it would silently drop the + * override and resolve the hostname through the very resolver being worked + * around. The transport must therefore be forced wherever the override is + * applied, not where it was created. + */ + public function testPinnedIpForcesCurlTransport() + { + global $apbct; + + $apbct->settings['wp__use_builtin_http_api'] = 1; + + $ct = new Cleantalk(); + $ct->dns_resolve_ip = '88.198.153.60'; + + $this->callPrivate($ct, 'sendRequest', [['method_name' => 'check_message'], self::DEAD_URL, 1]); + + $this->assertFalse( + $apbct->settings['wp__use_builtin_http_api'], + 'A request carrying CURLOPT_RESOLVE must not be sent through the WP HTTP API' + ); + } + + /** + * Without an override there is nothing a non-cURL transport could lose, so the + * user's own transport preference must be left untouched. + */ + public function testPlainRequestKeepsTransportPreference() + { + global $apbct; + + $apbct->settings['wp__use_builtin_http_api'] = 0; + + $ct = new Cleantalk(); + + $this->callPrivate($ct, 'sendRequest', [['method_name' => 'check_message'], self::DEAD_URL, 1]); + + $this->assertSame( + 0, + $apbct->settings['wp__use_builtin_http_api'], + 'Transport preference must only change when an override is actually applied' + ); + } +} diff --git a/tests/ApbctWP/TestModerateServerConfig.php b/tests/ApbctWP/TestModerateServerConfig.php new file mode 100644 index 000000000..83c39544e --- /dev/null +++ b/tests/ApbctWP/TestModerateServerConfig.php @@ -0,0 +1,297 @@ +hydrateFrom(array( + 'ct_work_url' => 'https://moderate.cleantalk.org', + 'ct_server_ttl' => 10, + 'ct_server_changed' => time(), + 'ct_resolve_ip' => '88.198.153.60', + )); + + $this->assertSame('https://moderate.cleantalk.org', $ct->work_url); + $this->assertSame('88.198.153.60', $ct->dns_resolve_ip); + $this->assertSame(APBCT_MODERATE_URL, $ct->server_url); + } + + public function testRecordWithoutPinLeavesResolutionAlone() + { + $ct = $this->hydrateFrom(array( + 'ct_work_url' => 'https://moderate3.cleantalk.org', + 'ct_server_ttl' => 10, + 'ct_server_changed' => time(), + )); + + $this->assertSame('https://moderate3.cleantalk.org', $ct->work_url); + $this->assertNull($ct->dns_resolve_ip); + } + + /** + * Older versions wrote a bare IP into the URL, which fails certificate + * verification. Such records must be discarded so a fresh rotation happens. + */ + public function testLegacyIpUrlIsRejected() + { + $ct = $this->hydrateFrom(array( + 'ct_work_url' => 'https://88.198.153.60', + 'ct_server_ttl' => 10, + 'ct_server_changed' => time(), + 'ct_resolve_ip' => '88.198.153.60', + )); + + $this->assertNull($ct->work_url); + $this->assertNull($ct->dns_resolve_ip); + } + + public function testNonHttpsUrlIsRejected() + { + $ct = $this->hydrateFrom(array( + 'ct_work_url' => 'http://moderate.cleantalk.org', + 'ct_server_ttl' => 10, + 'ct_server_changed' => time(), + )); + + $this->assertNull($ct->work_url); + } + + public function testForeignHostIsRejected() + { + $ct = $this->hydrateFrom(array( + 'ct_work_url' => 'https://moderate.cleantalk.org.scum.com', + 'ct_server_ttl' => 10, + 'ct_server_changed' => time(), + )); + + $this->assertNull($ct->work_url); + } + + public function testInvalidPinnedIpIsDropped() + { + $ct = $this->hydrateFrom(array( + 'ct_work_url' => 'https://moderate.cleantalk.org', + 'ct_server_ttl' => 10, + 'ct_server_changed' => time(), + 'ct_resolve_ip' => 'not-an-ip', + )); + + $this->assertSame('https://moderate.cleantalk.org', $ct->work_url); + $this->assertNull($ct->dns_resolve_ip); + } + + public function testMissingOptionYieldsEmptyState() + { + $ct = new Cleantalk(); + ModerateServerConfig::set($ct); + + $this->assertNull($ct->work_url); + $this->assertNull($ct->dns_resolve_ip); + $this->assertSame(APBCT_MODERATE_URL, $ct->server_url); + } + + public function testSaveSkippedWhenNothingRotated() + { + $ct = new Cleantalk(); + $ct->work_url = 'https://moderate4.cleantalk.org'; + + ModerateServerConfig::save($ct, false); + + // Nothing rotated -> the option must stay untouched, so a later successful + // rotation is not shadowed by a stale record. + $this->assertFalse(get_option(ModerateServerConfig::OPTION_NAME)); + } + + /** + * Picking a candidate is not proof that it answers. Caching an unverified server + * would aim every request for the next 24h at a host already known to fail, so a + * dead-end rotation must clear the record instead of storing a guess. + */ + public function testUnverifiedRotationClearsRecordInsteadOfCachingIt() + { + $ct = new Cleantalk(); + $ct->work_url = 'https://moderate4.cleantalk.org'; + $ct->server_ttl = 7; + $ct->server_state_dirty = true; + $ct->server_change = false; + + ModerateServerConfig::save($ct, false); + + $stored = get_option(ModerateServerConfig::OPTION_NAME); + $this->assertIsArray($stored); + $this->assertNull($stored['ct_work_url'], 'An unverified server must never be cached'); + $this->assertSame(0, $stored['ct_server_changed'], 'The cache window must not reopen'); + } + + /** + * rotateModerate() drops the pin before it starts looking for a server. If that + * rotation ends up finding nothing, the drop must still reach the option -- + * otherwise the stale IP is restored on the next request and keeps forcing + * CURLOPT_RESOLVE towards a host that may be gone. + */ + public function testClearedPinDoesNotSurviveDeadEndRotation() + { + $pinned = new Cleantalk(); + $pinned->work_url = 'https://moderate.cleantalk.org'; + $pinned->dns_resolve_ip = '88.198.153.60'; + $pinned->server_change = true; + ModerateServerConfig::save($pinned, false); + + $stored = get_option(ModerateServerConfig::OPTION_NAME); + $this->assertSame('88.198.153.60', $stored['ct_resolve_ip'], 'Precondition'); + + // Rotation cleared the pin but never confirmed a replacement. + $rotated = new Cleantalk(); + $rotated->dns_resolve_ip = null; + $rotated->server_state_dirty = true; + $rotated->server_change = false; + ModerateServerConfig::save($rotated, false); + + $this->assertNull( + get_option(ModerateServerConfig::OPTION_NAME)['ct_resolve_ip'], + 'A dropped pin must not be restorable from the option' + ); + } + + public function testSaveStoresRotatedState() + { + $ct = new Cleantalk(); + $ct->work_url = 'https://moderate4.cleantalk.org'; + $ct->server_ttl = 7; + $ct->dns_resolve_ip = '159.69.51.30'; + $ct->server_change = true; + + ModerateServerConfig::save($ct, false); + + $stored = get_option(ModerateServerConfig::OPTION_NAME); + $this->assertIsArray($stored); + $this->assertSame('https://moderate4.cleantalk.org', $stored['ct_work_url']); + $this->assertSame(7, $stored['ct_server_ttl']); + $this->assertSame('159.69.51.30', $stored['ct_resolve_ip']); + } + + /** + * A pinned IP must survive a save/load round trip, otherwise the override would + * be lost on the very next request and the broken resolver hit again. + */ + public function testPinnedIpSurvivesRoundTrip() + { + $saved = new Cleantalk(); + $saved->work_url = 'https://moderate.cleantalk.org'; + $saved->server_ttl = 5; + $saved->dns_resolve_ip = '159.69.51.30'; + $saved->server_change = true; + ModerateServerConfig::save($saved, false); + + $loaded = new Cleantalk(); + ModerateServerConfig::set($loaded); + + $this->assertSame('https://moderate.cleantalk.org', $loaded->work_url); + $this->assertSame('159.69.51.30', $loaded->dns_resolve_ip); + } + + /** + * Clearing the pin is how the plugin returns to ordinary name resolution. + */ + public function testSaveClearsPreviouslyPinnedIp() + { + update_option(ModerateServerConfig::OPTION_NAME, array( + 'ct_work_url' => 'https://moderate.cleantalk.org', + 'ct_server_ttl' => 5, + 'ct_server_changed' => time(), + 'ct_resolve_ip' => '88.198.153.60', + )); + + $ct = new Cleantalk(); + $ct->work_url = 'https://moderate3.cleantalk.org'; + $ct->server_ttl = 5; + $ct->dns_resolve_ip = null; + $ct->server_change = true; + ModerateServerConfig::save($ct, false); + + $reloaded = new Cleantalk(); + ModerateServerConfig::set($reloaded); + + $this->assertNull($reloaded->dns_resolve_ip); + } + + public function testResetDropsEveryField() + { + update_option(ModerateServerConfig::OPTION_NAME, array( + 'ct_work_url' => 'https://moderate.cleantalk.org', + 'ct_server_ttl' => 5, + 'ct_server_changed' => time(), + 'ct_resolve_ip' => '88.198.153.60', + )); + + ModerateServerConfig::reset(); + + $ct = new Cleantalk(); + ModerateServerConfig::set($ct); + + $this->assertNull($ct->work_url); + $this->assertNull($ct->dns_resolve_ip); + } + + /** + * Legacy records were stored without a scheme. getCurrentConfig() must keep them + * readable -- the https requirement belongs to set(), not to the raw accessor. + */ + public function testGetCurrentConfigKeepsSchemelessLegacyUrl() + { + update_option(ModerateServerConfig::OPTION_NAME, array( + 'ct_work_url' => 'moderate.cleantalk.org', + 'ct_server_ttl' => 100, + 'ct_server_changed' => time(), + )); + + $config = ModerateServerConfig::getCurrentConfig(); + + $this->assertSame('moderate.cleantalk.org', $config['ct_work_url']); + } + + public function testGetCurrentConfigAlwaysReturnsAllKeys() + { + $config = ModerateServerConfig::getCurrentConfig(); + + $this->assertIsArray($config); + foreach ( ['ct_work_url', 'ct_server_ttl', 'ct_server_changed', 'ct_resolve_ip'] as $key ) { + $this->assertArrayHasKey($key, $config); + } + } +} diff --git a/tests/Common/HTTP/TestRequestResolveOption.php b/tests/Common/HTTP/TestRequestResolveOption.php new file mode 100644 index 000000000..49d11c255 --- /dev/null +++ b/tests/Common/HTTP/TestRequestResolveOption.php @@ -0,0 +1,118 @@ +setAccessible(true); + $method->invoke($this); + + $this->appendOptionsObligatory(); + $this->processPresets(); + + return $this->options; + } +} + +/** + * The 'resolve' option pins a hostname to an IP via CURLOPT_RESOLVE. + * + * This is what lets the plugin bypass a broken system resolver WITHOUT putting a + * bare IP into the URL -- doing that breaks TLS, because the CleanTalk certificate + * only carries '*.cleantalk.org' and no IP SANs. + */ +class TestRequestResolveOption extends TestCase +{ + public function testResolveOptionIsConvertedToCurlResolve() + { + $request = new RequestResolveHarness(); + $request + ->setUrl('https://moderate.cleantalk.org') + ->setOptions(['resolve' => ['moderate.cleantalk.org:443:88.198.153.60']]); + + $options = $request->prepareOptionsForTest(); + + $this->assertArrayHasKey(CURLOPT_RESOLVE, $options); + $this->assertSame( + ['moderate.cleantalk.org:443:88.198.153.60'], + $options[CURLOPT_RESOLVE] + ); + } + + public function testResolveOptionDoesNotWeakenSslVerification() + { + $request = new RequestResolveHarness(); + $request + ->setUrl('https://moderate.cleantalk.org') + ->setOptions(['resolve' => ['moderate.cleantalk.org:443:88.198.153.60']]); + + $options = $request->prepareOptionsForTest(); + + $this->assertTrue($options[CURLOPT_SSL_VERIFYPEER]); + $this->assertSame(2, $options[CURLOPT_SSL_VERIFYHOST]); + } + + public function testUrlKeepsHostnameSoCertificateStillMatches() + { + $request = new RequestResolveHarness(); + $request + ->setUrl('https://moderate.cleantalk.org') + ->setOptions(['resolve' => ['moderate.cleantalk.org:443:88.198.153.60']]); + + $options = $request->prepareOptionsForTest(); + + $this->assertSame('https://moderate.cleantalk.org', $options[CURLOPT_URL]); + $this->assertStringNotContainsString('88.198.153.60', $options[CURLOPT_URL]); + } + + public function testEmptyResolveOptionIsDropped() + { + $request = new RequestResolveHarness(); + $request + ->setUrl('https://moderate.cleantalk.org') + ->setOptions(['resolve' => []]); + + $options = $request->prepareOptionsForTest(); + + $this->assertArrayNotHasKey(CURLOPT_RESOLVE, $options); + } + + public function testNonArrayResolveOptionIsDropped() + { + $request = new RequestResolveHarness(); + $request + ->setUrl('https://moderate.cleantalk.org') + ->setOptions(['resolve' => 'moderate.cleantalk.org:443:88.198.153.60']); + + $options = $request->prepareOptionsForTest(); + + $this->assertArrayNotHasKey(CURLOPT_RESOLVE, $options); + } + + /** + * String option names must never leak into curl_setopt_array(). + */ + public function testResolveStringKeyIsRemovedFromOptions() + { + $request = new RequestResolveHarness(); + $request + ->setUrl('https://moderate.cleantalk.org') + ->setOptions(['resolve' => ['moderate.cleantalk.org:443:88.198.153.60']]); + + $options = $request->prepareOptionsForTest(); + + $this->assertArrayNotHasKey('resolve', $options); + foreach ( array_keys($options) as $key ) { + $this->assertIsInt($key, 'Only cURL constants may remain in options'); + } + } +} diff --git a/tests/StandaloneFunctions/TestGetServerUrl.php b/tests/StandaloneFunctions/TestGetServerUrl.php index ed9f18214..73beab21b 100644 --- a/tests/StandaloneFunctions/TestGetServerUrl.php +++ b/tests/StandaloneFunctions/TestGetServerUrl.php @@ -1,7 +1,7 @@ time(), ) ); - $server_details = ct_get_server(); + $server_details = ModerateServerConfig::getCurrentConfig(); $this->assertIsArray($server_details); $this->assertArrayHasKey('ct_work_url', $server_details); $this->assertNotNull($server_details['ct_work_url']); @@ -60,7 +60,7 @@ public function testIncorrectUrls() 'ct_server_changed' => time(), ) ); - $server_details = ct_get_server(); + $server_details = ModerateServerConfig::getCurrentConfig(); $this->assertIsArray($server_details); $this->assertArrayHasKey('ct_work_url', $server_details); $this->assertIsNotString($server_details['ct_work_url']);