diff --git a/cleantalk.php b/cleantalk.php index 5fe60d4d7..5d68edfd8 100644 --- a/cleantalk.php +++ b/cleantalk.php @@ -782,14 +782,17 @@ function apbct_wpms__delete_blog(WP_Site $old_site) // if User is not logged in // if Unknown action or Known action with mandatory check if ( - ( ! apbct_is_user_logged_in() || $apbct->settings['data__protect_logged_in'] == 1) && Post::get('action') !== '' && ( ! in_array(Post::get('action'), $_cleantalk_hooked_actions) || in_array(Post::get('action'), $_cleantalk_ajax_actions_to_check) ) ) { - add_action('plugins_loaded', 'ct_ajax_hook'); + add_action('plugins_loaded', function () use ($apbct) { + if ( ! apbct_is_user_logged_in() || $apbct->settings['data__protect_logged_in'] == 1 ) { + ct_ajax_hook(); + } + }); } //QAEngine Theme answers diff --git a/inc/cleantalk-pluggable.php b/inc/cleantalk-pluggable.php index f1876b3a8..71b5b43aa 100644 --- a/inc/cleantalk-pluggable.php +++ b/inc/cleantalk-pluggable.php @@ -4,6 +4,8 @@ use Cleantalk\ApbctWP\Constant; use Cleantalk\ApbctWP\Helper; use Cleantalk\ApbctWP\RemoteCalls; +use Cleantalk\ApbctWP\Variables\AltSessions; +use Cleantalk\ApbctWP\Variables\Cookie; use Cleantalk\ApbctWP\Variables\Get; use Cleantalk\ApbctWP\Variables\Post; use Cleantalk\ApbctWP\Variables\Request; @@ -82,6 +84,10 @@ function apbct_wp_set_current_user($user = null) */ function apbct_wp_validate_auth_cookie($cookie = '', $scheme = '') { + if ( function_exists('wp_validate_auth_cookie') ) { + return wp_validate_auth_cookie($cookie, $scheme); + } + $cookie_elements = apbct_wp_parse_auth_cookie($cookie, $scheme); if (!is_array($cookie_elements) || empty($cookie_elements)) { @@ -109,17 +115,8 @@ function apbct_wp_validate_auth_cookie($cookie = '', $scheme = '') $algo = function_exists('hash') ? 'sha256' : 'sha1'; $hash = hash_hmac($algo, $username . '|' . $expiration . '|' . $token, $key); if ( hash_equals($hash, $hmac) && is_object($user) ) { - $sessions = get_user_meta($user->ID, 'session_tokens', true); - $sessions = is_array($sessions) ? current($sessions) : $sessions; - if ( is_array($sessions) ) { - if ( isset($sessions['expiration']) && is_int($sessions['expiration']) && $sessions['expiration'] > time() ) { - return $user->ID; - } else { - return false; - } - } else { - return false; - } + // The session of this very token must be active, not just any session of the user. + return WP_Session_Tokens::get_instance($user->ID)->verify($token) ? $user->ID : false; } else { return false; } @@ -412,16 +409,18 @@ function apbct_is_cli() } /** - * Checks if the user is logged in + * Checks WordPress authentication. Before pluggable functions load, no user is trusted. * * @return bool */ function apbct_is_user_logged_in() { - $siteurl = get_site_option('siteurl'); - $cookiehash = $siteurl ? md5($siteurl) : ''; + if ( did_action('set_current_user') && function_exists('is_user_logged_in') ) { + return is_user_logged_in(); + } - return count($_COOKIE) && isset($_COOKIE['wordpress_logged_in_' . $cookiehash]); + // Resolving the current user here would freeze it before themes/plugins add determine_current_user filters. + return function_exists('wp_validate_auth_cookie') && (bool)wp_validate_auth_cookie('', 'logged_in'); } function apbct_is_post() @@ -2297,7 +2296,8 @@ function apbct__is_wordpress_loopback_request() } /** - * Generates MD5 hash for email encoder pass key + * Generates MD5 hash for email encoder pass key. + * The key is public, so it only hints that the visitor may have passed the decoding check. * * @return string */ @@ -2305,7 +2305,33 @@ function apbct_get_email_encoder_pass_key() { global $apbct; - return md5(Helper::ipGet() . $apbct->api_key . 'email_encoder'); + return md5(Helper::ipGet('real', false) . $apbct->api_key . 'email_encoder'); +} + +/** + * Remembers on the server side that the visitor passed the contact decoding check. + * + * @return void + */ +function apbct_email_encoder_set_passed() +{ + AltSessions::set('apbct_email_encoder_verified', '1'); +} + +/** + * Checks whether contacts may be shown without encoding to the visitor who passed the decoding check. + * + * @return bool + */ +function apbct_is_email_encoder_passed() +{ + // Cheap check first, so the sessions storage is queried only for visitors who decoded contacts. + if ( Cookie::get('apbct_email_encoder_passed') !== apbct_get_email_encoder_pass_key() ) { + return false; + } + + // The verified mark is not in the list of values a visitor can set remotely. + return AltSessions::get('apbct_email_encoder_verified') === '1'; } /** diff --git a/inc/cleantalk-public-validate.php b/inc/cleantalk-public-validate.php index e748b2240..08e9ce029 100644 --- a/inc/cleantalk-public-validate.php +++ b/inc/cleantalk-public-validate.php @@ -98,7 +98,7 @@ function ct_contact_form_validate() } // Do not execute anti-spam test for logged in users. - if ( defined('LOGGED_IN_COOKIE') && isset($_COOKIE[LOGGED_IN_COOKIE]) && $apbct->settings['data__protect_logged_in'] != 1 ) { + if ( $apbct->settings['data__protect_logged_in'] != 1 && apbct_is_user_logged_in() ) { do_action('apbct_skipped_request', __FILE__ . ' -> ' . __FUNCTION__ . '():' . __LINE__, $_POST); return null; diff --git a/lib/Cleantalk/ApbctWP/Antispam/ForceProtection.php b/lib/Cleantalk/ApbctWP/Antispam/ForceProtection.php index e3ed2587e..715ef00d0 100644 --- a/lib/Cleantalk/ApbctWP/Antispam/ForceProtection.php +++ b/lib/Cleantalk/ApbctWP/Antispam/ForceProtection.php @@ -6,6 +6,7 @@ use Cleantalk\ApbctWP\ContactsEncoder\PlaceholderNonce; use Cleantalk\ApbctWP\Helper; use Cleantalk\ApbctWP\RequestParameters\RequestParameters; +use Cleantalk\ApbctWP\Variables\AltSessions; use Cleantalk\Variables\Post; use Cleantalk\Antispam\CleantalkRequest; use Cleantalk\Antispam\Cleantalk; @@ -109,7 +110,12 @@ public function checkBot() ), )); } - RequestParameters::set('apbct_force_protection_check', TT::toString($api_response->allow)); + $pass_token = '0'; + if ( (int)$api_response->allow === 1 ) { + $expires = time() + DAY_IN_SECONDS; + $pass_token = $expires . '.' . $this->getPassSignature($expires); + } + RequestParameters::set('apbct_force_protection_check', $pass_token); return json_encode(array( 'allow' => $api_response->allow, @@ -126,11 +132,15 @@ public function checkBot() */ private function checkIframeForms($content) { - $content = preg_replace_callback('#]*>.*?<\/iframe>#is', function ($matches) { + $is_passed = null; + $content = preg_replace_callback('#]*>.*?<\/iframe>#is', function ($matches) use (&$is_passed) { $iframe = isset($matches[0]) ? $matches[0] : ''; - if ( RequestParameters::get('apbct_force_protection_check') ) { + if ( $is_passed === null ) { + $is_passed = $this->hasValidPassToken(); + } + if ( $is_passed ) { return $iframe; } @@ -155,6 +165,42 @@ private function checkIframeForms($content) return $content; } + /** + * @return bool + */ + private function hasValidPassToken() + { + $token = RequestParameters::get('apbct_force_protection_check'); + if ( + ! is_string($token) || + ! preg_match('/^([0-9]{1,10})\.([a-f0-9]{64})$/D', $token, $parts) || + ! isset($parts[1], $parts[2]) + ) { + return false; + } + + $expires = (int)$parts[1]; + if ( $expires <= time() || $expires > time() + DAY_IN_SECONDS ) { + return false; + } + + return hash_equals($this->getPassSignature($expires), $parts[2]); + } + + /** + * Bind a successful challenge to this site, visitor and expiration time. + * + * @param int $expires + * @return string + */ + private function getPassSignature($expires) + { + $data = 'apbct_force_protection_check|' + . get_home_url() . '|' . AltSessions::getID() . '|' . $expires; + + return hash_hmac('sha256', $data, wp_salt('auth')); + } + /** * Check external forms. * diff --git a/lib/Cleantalk/ApbctWP/ContactsEncoder/ContactsEncoder.php b/lib/Cleantalk/ApbctWP/ContactsEncoder/ContactsEncoder.php index 80402c4b7..3594aa7a4 100644 --- a/lib/Cleantalk/ApbctWP/ContactsEncoder/ContactsEncoder.php +++ b/lib/Cleantalk/ApbctWP/ContactsEncoder/ContactsEncoder.php @@ -46,9 +46,15 @@ class ContactsEncoder extends \Cleantalk\Common\ContactsEncoder\ContactsEncoder */ private $comment; + /** + * @var Params + */ + private $params; + public function init($params) { parent::init($params); + $this->params = $params; $this->exclusions = new ExclusionsService($params); $this->shortcodes = new ShortCodesService($params); $this->grid_builder_integration = new CEIntegrationGridBuilder(); @@ -56,6 +62,25 @@ public function init($params) $this->shortcodes->registerAll(); $this->registerHookHandler(); + + // The instance is created on plugin load, before WordPress authenticates the visitor. + add_action('set_current_user', array($this, 'syncLoggedInState')); + } + + /** + * @return void + */ + public function syncLoggedInState() + { + $is_logged_in = apbct_is_user_logged_in(); + if ( $is_logged_in === $this->is_logged_in ) { + return; + } + + // Params are shared with the exclusion services. + $this->params->is_logged_in = $is_logged_in; + $this->is_logged_in = $is_logged_in; + $this->encoder->useSSL(! $is_logged_in); } /** @@ -814,6 +839,9 @@ public function ajaxDecodeEmailHandler() $response = $this->compileResponse($this->decoded_contacts_array, false); wp_send_json_error($response); } + if ( ! $this->is_logged_in && ! empty($this->decoded_contacts_array) && $this->is_encode_allowed ) { + apbct_email_encoder_set_passed(); + } $response = $this->compileResponse($this->decoded_contacts_array, $this->is_encode_allowed); wp_send_json_success($response); } diff --git a/lib/Cleantalk/ApbctWP/ContactsEncoder/Exclusions/ExclusionsService.php b/lib/Cleantalk/ApbctWP/ContactsEncoder/Exclusions/ExclusionsService.php index f6746c583..0f9152b6b 100644 --- a/lib/Cleantalk/ApbctWP/ContactsEncoder/Exclusions/ExclusionsService.php +++ b/lib/Cleantalk/ApbctWP/ContactsEncoder/Exclusions/ExclusionsService.php @@ -3,7 +3,6 @@ namespace Cleantalk\ApbctWP\ContactsEncoder\Exclusions; use Cleantalk\ApbctWP\State; -use Cleantalk\ApbctWP\Variables\Cookie; use Cleantalk\ApbctWP\Variables\Post; use Cleantalk\ApbctWP\Variables\Server; use Cleantalk\Common\TT; @@ -151,9 +150,7 @@ private function byServerVars() */ private function byDecoderPassedCookie() { - $apbct_email_encoder_passed = Cookie::get('apbct_email_encoder_passed'); - - return $apbct_email_encoder_passed === apbct_get_email_encoder_pass_key(); + return apbct_is_email_encoder_passed(); } /** diff --git a/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/EncodeContentSC.php b/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/EncodeContentSC.php index ae4189f06..ad20305fa 100644 --- a/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/EncodeContentSC.php +++ b/lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/EncodeContentSC.php @@ -4,7 +4,6 @@ use Cleantalk\ApbctWP\ContactsEncoder\ContactsEncoder; use Cleantalk\ApbctWP\Escape; -use Cleantalk\ApbctWP\Variables\Cookie; use Cleantalk\Common\ContactsEncoder\Dto\Params; use Cleantalk\ApbctWP\ContactsEncoder\Exclusions\ExclusionsService; @@ -68,7 +67,7 @@ public function callback($_atts, $content, $_tag) return ''; } - if ( Cookie::get('apbct_email_encoder_passed') === apbct_get_email_encoder_pass_key() ) { + if ( apbct_is_email_encoder_passed() ) { return (string)$content; } diff --git a/lib/Cleantalk/ApbctWP/Variables/AltSessions.php b/lib/Cleantalk/ApbctWP/Variables/AltSessions.php index b46b1628d..19d07903d 100644 --- a/lib/Cleantalk/ApbctWP/Variables/AltSessions.php +++ b/lib/Cleantalk/ApbctWP/Variables/AltSessions.php @@ -55,8 +55,7 @@ class AltSessions public static function getID() { // Accept-Language is omitted: XHR and document POST often send different headers. - $id = Helper::ipGet() - . Server::getString('HTTP_USER_AGENT'); + $id = Helper::ipGet('real', false) . Server::getString('HTTP_USER_AGENT'); return substr(hash('sha256', $id), -16); } diff --git a/tests/ApbctWP/Antispam/ForceProtectionTest.php b/tests/ApbctWP/Antispam/ForceProtectionTest.php new file mode 100644 index 000000000..a1019c90a --- /dev/null +++ b/tests/ApbctWP/Antispam/ForceProtectionTest.php @@ -0,0 +1,226 @@ +'; + + protected function setUp(): void + { + parent::setUp(); + global $apbct; + + $this->original_cookies = $_COOKIE; + $this->original_server = $_SERVER; + $this->original_no_cookies = NoCookie::$no_cookies_data; + $this->original_user_id = get_current_user_id(); + $this->original_cookies_type = $apbct->data['cookies_type']; + wp_set_current_user(0); + $_COOKIE = array(); + NoCookie::$no_cookies_data = array(); + $this->resetRequestCache(); + $this->protection = new ForceProtection(); + } + + protected function tearDown(): void + { + global $apbct; + + remove_filter('the_content', array($this->protection, 'modifyContent')); + $_COOKIE = $this->original_cookies; + $_SERVER = $this->original_server; + $this->resetIpCache(); + NoCookie::$no_cookies_data = $this->original_no_cookies; + $apbct->data['cookies_type'] = $this->original_cookies_type; + wp_set_current_user($this->original_user_id); + $this->resetRequestCache(); + parent::tearDown(); + } + + private function resetRequestCache() + { + Cookie::getInstance()->variables = array(); + Server::getInstance()->variables = array(); + } + + private function resetIpCache() + { + $property = new \ReflectionProperty(\Cleantalk\Common\Helper::class, 'ips_stored'); + $property->setAccessible(true); + $property->setValue(Helper::getInstance(), array()); + Server::getInstance()->variables = array(); + } + + private function setPassToken($mode, $token) + { + global $apbct; + $apbct->data['cookies_type'] = $mode; + if ( $mode === 'native' ) { + $_COOKIE[apbct__get_cookie_prefix() . 'apbct_force_protection_check'] = $token; + $this->resetRequestCache(); + } else { + $this->assertNotFalse(RequestParameters::set('apbct_force_protection_check', $token)); + } + } + + private function signToken($expires) + { + $method = new \ReflectionMethod(ForceProtection::class, 'getPassSignature'); + $method->setAccessible(true); + + return $expires . '.' . $method->invoke($this->protection, $expires); + } + + public function storageModes() + { + return array( + 'native' => array('native'), + 'alternative' => array('alternative'), + 'no cookies' => array('none'), + ); + } + + /** + * @dataProvider storageModes + */ + public function testUnsignedAndInvalidPassTokensAreRejected($mode) + { + $expires = time() + HOUR_IN_SECONDS; + $valid_token = $this->signToken($expires); + $invalid_tokens = array( + '0', + '1', + 'forged', + $expires . '.' . str_repeat('0', 64), + $this->signToken(time() - 1), + $this->signToken(time() + 2 * DAY_IN_SECONDS), + ($expires + 1) . substr($valid_token, strpos($valid_token, '.')), + $valid_token . '.extra', + ); + + foreach ( $invalid_tokens as $token ) { + $this->setPassToken($mode, $token); + $this->assertStringContainsString( + 'ct-encoded-form-wrapper', + $this->protection->modifyContent(self::IFRAME), + $token + ); + } + } + + /** + * @dataProvider storageModes + */ + public function testValidPassTokenPreservesIframe($mode) + { + $this->setPassToken($mode, $this->signToken(time() + HOUR_IN_SECONDS)); + + $this->assertSame(self::IFRAME, $this->protection->modifyContent(self::IFRAME)); + } + + /** + * @dataProvider storageModes + */ + public function testPassTokenCannotBeReusedByAnotherVisitor($mode) + { + $this->setPassToken($mode, $this->signToken(time() + HOUR_IN_SECONDS)); + $_SERVER['HTTP_USER_AGENT'] = 'another-visitor'; + $this->resetRequestCache(); + + $this->assertStringContainsString( + 'ct-encoded-form-wrapper', + $this->protection->modifyContent(self::IFRAME) + ); + } + + public function testPassTokenIsBoundToSite() + { + $this->setPassToken('native', $this->signToken(time() + HOUR_IN_SECONDS)); + $other_site = function () { + return 'https://another-site.example'; + }; + add_filter('home_url', $other_site); + try { + $this->assertStringContainsString( + 'ct-encoded-form-wrapper', + $this->protection->modifyContent(self::IFRAME) + ); + } finally { + remove_filter('home_url', $other_site); + } + } + + /** + * @dataProvider storageModes + */ + public function testPassTokenSurvivesIpLookupOrderForIpv6Visitor($mode) + { + $_SERVER['REMOTE_ADDR'] = '2001:db8::10'; + $this->resetIpCache(); + + // The challenge request resolves the IPv6 address before the token is issued. + Helper::ipGet('real', false); + $token = $this->signToken(time() + HOUR_IN_SECONDS); + if ( $mode === 'alternative' ) { + $this->setPassToken($mode, $token); + } + + // A page request may only run the default IPv4-only lookup. + $this->resetIpCache(); + Helper::ipGet('real'); + if ( $mode !== 'alternative' ) { + $this->setPassToken($mode, $token); + } + + $this->assertSame(self::IFRAME, $this->protection->modifyContent(self::IFRAME)); + } + + public function testForgedLoginCookieDoesNotBypassProtection() + { + $_COOKIE[LOGGED_IN_COOKIE] = 'x'; + $_COOKIE['wordpress_logged_in_' . md5(get_site_option('siteurl'))] = 'x'; + + $this->assertStringContainsString( + 'ct-encoded-form-wrapper', + $this->protection->modifyContent(self::IFRAME) + ); + } + + public function testAuthenticatedUserStillSkipsProtection() + { + $user_id = wp_insert_user(array( + 'user_login' => 'force-protection-auth-test', + 'user_pass' => 'test-password', + )); + $this->assertFalse(is_wp_error($user_id)); + wp_set_current_user($user_id); + try { + $this->assertSame(self::IFRAME, $this->protection->modifyContent(self::IFRAME)); + } finally { + wp_set_current_user(0); + wp_delete_user($user_id); + } + } + + public function testUnrelatedIframeIsUnchanged() + { + $iframe = ''; + + $this->assertSame($iframe, $this->protection->modifyContent($iframe)); + } +} diff --git a/tests/ApbctWP/ContactsEncoder/TestContactsEncoder.php b/tests/ApbctWP/ContactsEncoder/TestContactsEncoder.php index 8359a4b57..299fc527d 100644 --- a/tests/ApbctWP/ContactsEncoder/TestContactsEncoder.php +++ b/tests/ApbctWP/ContactsEncoder/TestContactsEncoder.php @@ -43,6 +43,7 @@ private function clearDecoderPassedCookie(): void { $cookie_name = apbct__get_cookie_prefix() . 'apbct_email_encoder_passed'; unset($_COOKIE[$cookie_name]); + \Cleantalk\ApbctWP\Variables\AltSessions::wipe(); $cookie_instance = Cookie::getInstance(); $ref = new \ReflectionClass($cookie_instance); @@ -71,6 +72,8 @@ private function setDecoderPassedCookie(): string $this->clearDecoderPassedCookie(); $_COOKIE[$cookie_name] = $pass_key; Cookie::set('apbct_email_encoder_passed', $pass_key); + // The cookie alone is public, the server mark is set after a successful decoding check. + apbct_email_encoder_set_passed(); return $pass_key; } diff --git a/tests/ApbctWP/ContactsEncoder/TestContactsEncoderShortCodeEncode.php b/tests/ApbctWP/ContactsEncoder/TestContactsEncoderShortCodeEncode.php index e0435a63c..c3e8e23a8 100644 --- a/tests/ApbctWP/ContactsEncoder/TestContactsEncoderShortCodeEncode.php +++ b/tests/ApbctWP/ContactsEncoder/TestContactsEncoderShortCodeEncode.php @@ -48,6 +48,7 @@ private function clearDecoderPassedCookie(): void { $cookie_name = apbct__get_cookie_prefix() . 'apbct_email_encoder_passed'; unset($_COOKIE[$cookie_name]); + \Cleantalk\ApbctWP\Variables\AltSessions::wipe(); $cookie_instance = Cookie::getInstance(); $ref = new \ReflectionClass($cookie_instance); @@ -75,6 +76,7 @@ private function setDecoderPassedCookie(): void $this->clearDecoderPassedCookie(); $_COOKIE[$cookie_name] = $pass_key; Cookie::set('apbct_email_encoder_passed', $pass_key); + apbct_email_encoder_set_passed(); } public function testCallbackEncodesContent() diff --git a/tests/Inc/TestContactFormValidate.php b/tests/Inc/TestContactFormValidate.php index c995cf89e..8996a9d03 100644 --- a/tests/Inc/TestContactFormValidate.php +++ b/tests/Inc/TestContactFormValidate.php @@ -101,10 +101,12 @@ public function testContactFormValidateWCExpressCheckout() $_REQUEST['security'] = wp_create_nonce('wc-stripe-express-checkout-normalize-address'); Request::getInstance()->variables = $_REQUEST; - add_action('apbct_skipped_request', function (...$arg) { + $assert_skip_reason = function (...$arg) { $this->assertStringContainsString('ct_contact_form_validate():WOOCOMMERCE_SERVICES', $arg[0]); - }, 10); + }; + add_action('apbct_skipped_request', $assert_skip_reason, 10); $result = ct_contact_form_validate(); + remove_action('apbct_skipped_request', $assert_skip_reason, 10); $this->assertNull($result); update_option( 'active_plugins', array(), true ); diff --git a/tests/StandaloneFunctions/AuthenticationTest.php b/tests/StandaloneFunctions/AuthenticationTest.php new file mode 100644 index 000000000..e9454604a --- /dev/null +++ b/tests/StandaloneFunctions/AuthenticationTest.php @@ -0,0 +1,285 @@ +original_cookies = $_COOKIE; + $_COOKIE = array(); + wp_set_current_user(0); + } + + public function tear_down() + { + $_COOKIE = $this->original_cookies; + wp_set_current_user(0); + parent::tear_down(); + } + + private function reloadCurrentUser() + { + unset($GLOBALS['current_user']); + } + + public function testForgedCookieDoesNotAuthenticate() + { + $_COOKIE['wordpress_logged_in_' . md5(get_site_option('siteurl'))] = 'x'; + $_COOKIE[LOGGED_IN_COOKIE] = 'x'; + $this->reloadCurrentUser(); + + $this->assertFalse(apbct_is_user_logged_in()); + } + + public function testValidCookieAuthenticates() + { + $user_id = self::factory()->user->create(); + $_COOKIE[LOGGED_IN_COOKIE] = wp_generate_auth_cookie($user_id, time() + HOUR_IN_SECONDS, 'logged_in'); + $this->reloadCurrentUser(); + + $this->assertTrue(apbct_is_user_logged_in()); + $this->assertSame($user_id, get_current_user_id()); + } + + public function testExpiredCookieDoesNotAuthenticate() + { + $user_id = self::factory()->user->create(); + $_COOKIE[LOGGED_IN_COOKIE] = wp_generate_auth_cookie($user_id, time() - DAY_IN_SECONDS, 'logged_in'); + $this->reloadCurrentUser(); + + $this->assertFalse(apbct_is_user_logged_in()); + } + + public function testRevokedSessionDoesNotAuthenticate() + { + $user_id = self::factory()->user->create(); + $_COOKIE[LOGGED_IN_COOKIE] = wp_generate_auth_cookie($user_id, time() + HOUR_IN_SECONDS, 'logged_in'); + WP_Session_Tokens::get_instance($user_id)->destroy_all(); + $this->reloadCurrentUser(); + + $this->assertFalse(apbct_is_user_logged_in()); + } + + public function testFallbackValidatorRejectsRevokedTokenWhileOtherSessionIsActive() + { + $user_id = self::factory()->user->create(); + $expiration = time() + HOUR_IN_SECONDS; + $manager = WP_Session_Tokens::get_instance($user_id); + $revoked_token = $manager->create($expiration); + $active_token = $manager->create($expiration); + $revoked_cookie = wp_generate_auth_cookie($user_id, $expiration, 'logged_in', $revoked_token); + $active_cookie = wp_generate_auth_cookie($user_id, $expiration, 'logged_in', $active_token); + + $manager->destroy($revoked_token); + + $this->assertFalse(apbct_wp_validate_auth_cookie($revoked_cookie, 'logged_in')); + $this->assertSame($user_id, apbct_wp_validate_auth_cookie($active_cookie, 'logged_in')); + } + + public function contactFormVisitors() + { + return array( + 'forged cookie' => array(false), + 'authenticated' => array(true), + ); + } + + /** + * @dataProvider contactFormVisitors + */ + public function testContactFormValidationSkipsOnlyAuthenticatedUsers($authenticated) + { + global $apbct; + + $settings = $apbct->settings; + $post = $_POST; + $skipped = array(); + $record_skip = function ($reason) use (&$skipped) { + $skipped[] = $reason; + }; + add_action('apbct_skipped_request', $record_skip); + + try { + $apbct->settings['data__protect_logged_in'] = 0; + $_POST = array('email' => 'visitor@example.com', 'message' => 'Hello'); + \Cleantalk\ApbctWP\Variables\Post::getInstance()->variables = array(); + \Cleantalk\ApbctWP\Variables\Get::getInstance()->variables = array(); + if ( $authenticated ) { + wp_set_current_user(self::factory()->user->create()); + } else { + $_COOKIE[LOGGED_IN_COOKIE] = 'x'; + $this->reloadCurrentUser(); + } + // The admin side skip goes right after the logged-in skip, so the cloud check is not reached. + set_current_screen('dashboard'); + + ct_contact_form_validate(); + + $this->assertNotEmpty($skipped); + if ( $authenticated ) { + $this->assertStringNotContainsString('Admin side request.', $skipped[0]); + } else { + $this->assertStringContainsString('Admin side request.', $skipped[0]); + } + } finally { + remove_action('apbct_skipped_request', $record_skip); + $GLOBALS['current_screen'] = null; + $apbct->settings = $settings; + $_POST = $post; + \Cleantalk\ApbctWP\Variables\Post::getInstance()->variables = array(); + } + } + + public function testPublicEmailEncoderPassKeyAloneDoesNotSkipEncoding() + { + global $apbct; + + $cookies_type = $apbct->data['cookies_type']; + $cookie_name = apbct__get_cookie_prefix() . 'apbct_email_encoder_passed'; + + try { + $apbct->data['cookies_type'] = 'native'; + \Cleantalk\ApbctWP\Variables\AltSessions::wipe(); + $_COOKIE[$cookie_name] = apbct_get_email_encoder_pass_key(); + \Cleantalk\ApbctWP\Variables\Cookie::getInstance()->variables = array(); + + $this->assertFalse(apbct_is_email_encoder_passed()); + + apbct_email_encoder_set_passed(); + $this->assertTrue(apbct_is_email_encoder_passed()); + + unset($_COOKIE[$cookie_name]); + \Cleantalk\ApbctWP\Variables\Cookie::getInstance()->variables = array(); + $this->assertFalse(apbct_is_email_encoder_passed()); + } finally { + \Cleantalk\ApbctWP\Variables\AltSessions::wipe(); + \Cleantalk\ApbctWP\Variables\Cookie::getInstance()->variables = array(); + $apbct->data['cookies_type'] = $cookies_type; + } + } + + public function testCurrentUserDoesNotRequireDefaultCookieName() + { + wp_set_current_user(self::factory()->user->create()); + + $this->assertEmpty($_COOKIE); + $this->assertTrue(apbct_is_user_logged_in()); + } + + public function testEarlyCheckValidatesCookieWithoutResolvingCurrentUser() + { + global $wp_actions; + + $user_id = self::factory()->user->create(); + $actions = $wp_actions; + try { + unset($wp_actions['set_current_user']); + + $_COOKIE[LOGGED_IN_COOKIE] = 'x'; + $this->reloadCurrentUser(); + $this->assertFalse(apbct_is_user_logged_in()); + + $_COOKIE[LOGGED_IN_COOKIE] = wp_generate_auth_cookie($user_id, time() + HOUR_IN_SECONDS, 'logged_in'); + $this->assertTrue(apbct_is_user_logged_in()); + + $this->assertArrayNotHasKey('current_user', $GLOBALS); + $this->assertSame(0, did_action('set_current_user')); + } finally { + $wp_actions = $actions; + } + } + + public function testEncoderCreatedBeforeAuthenticationFollowsCurrentUser() + { + global $apbct; + + $settings = $apbct->settings; + $api_key = $apbct->api_key; + $instance = new ReflectionProperty(\Cleantalk\Common\ContactsEncoder\ContactsEncoder::class, 'instance'); + $instance->setAccessible(true); + $original_encoder = $instance->getValue(); + $content = '

alice.secret@example.com

'; + + try { + $instance->setValue(null, null); + $apbct->api_key = 'test-api-key'; + $apbct->settings['data__email_decoder_encode_email_addresses'] = 1; + $apbct->settings['data__email_decoder_obfuscation_mode'] = + \Cleantalk\Common\ContactsEncoder\Dto\Params::OBFUSCATION_MODE_BLUR; + $encoder = apbctGetContactsEncoder(); + $this->assertStringNotContainsString('alice.secret@example.com', $encoder->modifyContent($content)); + + wp_set_current_user(self::factory()->user->create()); + $this->assertSame($content, $encoder->modifyContent($content)); + + wp_set_current_user(0); + $this->assertStringNotContainsString('alice.secret@example.com', $encoder->modifyContent($content)); + } finally { + if ( isset($encoder) ) { + remove_action('set_current_user', array($encoder, 'syncLoggedInState')); + } + $instance->setValue(null, $original_encoder); + $apbct->settings = $settings; + $apbct->api_key = $api_key; + } + } + + public function encoderVisitors() + { + return array( + 'anonymous' => array(false, false), + 'forged cookie' => array(true, false), + 'authenticated' => array(false, true), + ); + } + + /** + * @dataProvider encoderVisitors + */ + public function testContactEncoderUsesVerifiedAuthentication($forged_cookie, $authenticated) + { + global $apbct, $wp_filter, $shortcode_tags; + + $settings = $apbct->settings; + $api_key = $apbct->api_key; + $filters = $wp_filter; + $shortcodes = $shortcode_tags; + $instance = new ReflectionProperty(\Cleantalk\Common\ContactsEncoder\ContactsEncoder::class, 'instance'); + $instance->setAccessible(true); + $original_encoder = $instance->getValue(); + + try { + $instance->setValue(null, null); + $apbct->api_key = 'test-api-key'; + $apbct->settings['data__email_decoder_encode_email_addresses'] = 1; + $apbct->settings['data__email_decoder_obfuscation_mode'] = + \Cleantalk\Common\ContactsEncoder\Dto\Params::OBFUSCATION_MODE_BLUR; + if ( $forged_cookie ) { + $_COOKIE[LOGGED_IN_COOKIE] = 'x'; + $this->reloadCurrentUser(); + } + if ( $authenticated ) { + wp_set_current_user(self::factory()->user->create()); + } + + $content = '

alice.secret@example.com

'; + $result = apbctGetContactsEncoder()->modifyContent($content); + + if ( $authenticated ) { + $this->assertSame($content, $result); + } else { + $this->assertStringNotContainsString('alice.secret@example.com', $result); + $this->assertStringContainsString('apbct-email-encoder', $result); + $this->assertStringContainsString('data-original-string', $result); + } + } finally { + $instance->setValue(null, $original_encoder); + $apbct->settings = $settings; + $apbct->api_key = $api_key; + $wp_filter = $filters; + $shortcode_tags = $shortcodes; + } + } +}