diff --git a/cleantalk.php b/cleantalk.php index 72950a83d..f02571249 100644 --- a/cleantalk.php +++ b/cleantalk.php @@ -29,6 +29,7 @@ use Cleantalk\ApbctWP\Deactivator; use Cleantalk\ApbctWP\Firewall\AntiCrawler; use Cleantalk\ApbctWP\Firewall\AntiFlood; +use Cleantalk\ApbctWP\Firewall\FirewallBypass; use Cleantalk\ApbctWP\Firewall\SFW; use Cleantalk\ApbctWP\Firewall\SFWUpdateHelper; use Cleantalk\ApbctWP\Helper; @@ -898,7 +899,7 @@ function apbct_sfw__check() } } - // Skip the check + // Skip the check (legacy way, remove in future) if ( ! empty(Get::get('access')) ) { $spbc_settings = get_option('spbc_settings'); $spbc_key = ! empty($spbc_settings['spbc_key']) ? $spbc_settings['spbc_key'] : false; @@ -938,6 +939,12 @@ function apbct_sfw__check() return; } + // Emergency bypass: consume the one-time link from the admin email, then check the granted bypass. + FirewallBypass::maybeSetUserToken(); + if (FirewallBypass::bypassByUserToken()) { + return; + } + $firewall = new Firewall( DB::getInstance() ); diff --git a/lib/Cleantalk/ApbctWP/Escape.php b/lib/Cleantalk/ApbctWP/Escape.php index 12d4a2f11..f9c10b520 100644 --- a/lib/Cleantalk/ApbctWP/Escape.php +++ b/lib/Cleantalk/ApbctWP/Escape.php @@ -69,7 +69,7 @@ public static function escUrl($text) * * @param $text * - * @return string|null + * @return string */ public static function escUrlRaw($text) { diff --git a/lib/Cleantalk/ApbctWP/Firewall/FirewallBypass.php b/lib/Cleantalk/ApbctWP/Firewall/FirewallBypass.php new file mode 100644 index 000000000..9c1173e3b --- /dev/null +++ b/lib/Cleantalk/ApbctWP/Firewall/FirewallBypass.php @@ -0,0 +1,401 @@ +getMessage(); + return false; + } + } + + /** + * Checks whether the current visitor holds a valid bypass secret. + * + * Called on every request, so it must stay as cheap as possible: the storage is read + * only when a well-formed cookie is present. + * + * @return bool True if the firewall has to be skipped for this visitor. + */ + public static function bypassByUserToken() + { + $user_secret = self::getUserSecretFromRequest(); + if ( $user_secret === '' ) { + return false; + } + + $stored_hash = get_transient(self::USER_TOKEN_TRANSIENT_KEY); + if ( ! is_string($stored_hash) || $stored_hash === '' ) { + return false; + } + + // Constant time comparison to exclude any timing side channel. + return hash_equals($stored_hash, self::hashSecret($user_secret)); + } + + /** + * Consumes the one-time ready token from the request and grants the bypass to the current browser. + * + * Called on every request right before the firewall check, so it bails out immediately + * when the request carries no bypass parameter. + * + * @return void + */ + public static function maybeSetUserToken() + { + $ready_token_from_request = Request::getString(self::GENERATION_REQUEST_PARAM); + + // Do not touch the storage on regular requests. + if ( $ready_token_from_request === '' ) { + return; + } + + try { + $ready_token = self::loadReadyToken(); + if ( $ready_token === false ) { + return; + } + + // Constant time comparison to exclude any timing side channel. + if ( ! hash_equals($ready_token, $ready_token_from_request) ) { + return; + } + + // The link is single use: invalidate the token before the secret is issued. + self::removeReadyToken(); + + $user_secret = self::generateToken(); + if ( ! is_string($user_secret) ) { + throw new \Exception('Failed to generate bypass user secret.'); + } + + self::setUserToken($user_secret); + } catch (\Exception $e) { + self::$last_error = $e->getMessage(); + } + } + + /** + * Grants the bypass: stores the hash of the secret on the server and gives the secret to the browser. + * + * Only one bypass may be active per site, a new one replaces the previous. + * + * @param string $user_secret Plain secret issued to the browser. + * + * @return void + */ + private static function setUserToken($user_secret) + { + $hashed_secret = self::hashSecret($user_secret); + // Only the hash is persisted, so the storage dump does not allow to reproduce the cookie. + $transient_set = set_transient( + self::USER_TOKEN_TRANSIENT_KEY, + $hashed_secret, + self::USER_TOKEN_EXPIRATION + ); + + // set transient may fall as update_option with the same value returns false, so we need to check the transient value to be sure that it was set. + if (!$transient_set) { + $exist_transient = get_transient(self::USER_TOKEN_TRANSIENT_KEY); + if ( $exist_transient !== $hashed_secret) { + self::$last_error = 'Failed to store the bypass user secret.'; + return; + } + } + + // The ready token is already burned at this point, so a failed cookie must not abort the grant. + // The visitor keeps the bypass for the current request, but the browser gets nothing to reuse. + // setcookie() does not populate the superglobal, fill it to let the very same request pass, + // before the headers_sent() check, so a failed real cookie still leaves this request bypassed. + $_COOKIE[self::USER_COOKIE_NAME] = $user_secret; + + // The handler caches the read values, drop the cached miss to make the new secret visible at once. + unset(Cookie::getInstance()->variables[self::USER_COOKIE_NAME]); + + if ( headers_sent() ) { + self::$last_error = 'Headers are already sent, the bypass cookie has not been set.'; + return; + } + + // Cookie::set() routes the value to AltSessions/NoCookie storage depending on the plugin's + // cookie settings (and no-ops entirely when the API key is not validated), so the browser + // would never actually receive the secret. The bypass must not depend on any of that: + // it always uses a real native cookie regardless of the current cookies_type/key_is_ok. + Cookie::setNativeCookie( + self::USER_COOKIE_NAME, + $user_secret, + time() + self::USER_TOKEN_EXPIRATION, + '/', + '', + is_ssl(), + true, + 'Lax' + ); + } + + /** + * Extracts and validates the bypass secret sent by the browser. + * + * @return string The secret or an empty string if it is missing or malformed. + * @psalm-suppress RedundantCondition + */ + private static function getUserSecretFromRequest() + { + //notice: do not use Cookie::getInstance()->get() here, because it depends on the plugin's cookie settings and may return null + $secret = isset($_COOKIE[self::USER_COOKIE_NAME]) && is_string($_COOKIE[self::USER_COOKIE_NAME]) + ? $_COOKIE[self::USER_COOKIE_NAME] + : ''; + + // Strict shape check: the value is always a 64 chars hex string produced by self::generateToken(). + if ( ! preg_match('/^[a-f0-9]{64}$/', $secret) ) { + return ''; + } + + return $secret; + } + + /** + * Calculates the server side representation of the user secret. + * + * A plain hash without salt is enough here: the secret is a 256-bit random value, so it is not brute forceable. + * + * @param string $secret + * + * @return string Hex SHA-256 hash. + */ + private static function hashSecret($secret) + { + return hash('sha256', $secret); + } + + /** + * Generates a cryptographically secure 256-bit token. + * + * @return string|false 64 chars hex string or false if the system has no secure randomness source. + */ + private static function generateToken() + { + try { + $token = bin2hex(random_bytes(32)); + } catch (\Exception $e) { + // Not enough entropy. + return false; + } + + return $token; + } + + /** + * Persists the ready token emailed to the admin. + * + * @param string $token + * + * @return bool + */ + private static function saveReadyToken($token) + { + return (bool)set_transient(self::GENERATION_TOKEN_TRANSIENT_KEY, $token, self::GENERATION_TOKEN_EXPIRATION); + } + + /** + * Reads the currently active ready token. + * + * @return string|false The token or false if it is absent, expired or broken. + */ + private static function loadReadyToken() + { + $token = get_transient(self::GENERATION_TOKEN_TRANSIENT_KEY); + if ( ! is_string($token) || empty($token) ) { + return false; + } + + return $token; + } + + /** + * Invalidates the ready token. + * + * @return void + */ + private static function removeReadyToken() + { + delete_transient(self::GENERATION_TOKEN_TRANSIENT_KEY); + } + + /** + * Sends the bypass link to the site admin. + * + * @param string $ready_token + * + * @return bool + */ + private static function sendAdminEmail($ready_token) + { + $admin_email = self::getAdminEmail(); + if ( empty($admin_email) ) { + return false; + } + + return (bool)self::sendEmail($admin_email, self::getEmailSubject(), self::getEmailMessage($ready_token)); + } + + /** + * Recipient of the bypass link. + * + * @return string + */ + private static function getAdminEmail() + { + return ct_get_admin_email(); + } + + /** + * Builds the plain text body of the notification. + * + * @param string $ready_token + * + * @return string + */ + private static function getEmailMessage($ready_token) + { + // Raw escaping is used: the message is plain text, HTML entities would break the link. + $bypass_url = Escape::escUrlRaw( + add_query_arg(self::GENERATION_REQUEST_PARAM, $ready_token, get_site_url()) + ); + + // A single translatable string with numbered placeholders: splitting it would + // let a translation break sprintf() with a stray percent sign. + $template = __( + "A firewall bypass link has been generated for your site." + . " Use the link below to bypass the firewall:\n\n%1\$s\n\n" + . "The link is valid for %2\$d minutes and can be used only once." + . " The bypass will last for %3\$d minutes in the browser that opens the link.", + 'cleantalk-spam-protect' + ); + + return sprintf( + $template, + $bypass_url, + (self::GENERATION_TOKEN_EXPIRATION / 60), + (self::USER_TOKEN_EXPIRATION / 60) + ); + } + + /** + * Subject of the notification. + * + * @return string + */ + private static function getEmailSubject() + { + return __('Anti-Spam by CleanTalk plugin: Firewall Bypass Link Generated', 'cleantalk-spam-protect'); + } + + /** + * Wrapper over wp_mail() to keep the mailer replaceable in tests. + * + * @param string $admin_email + * @param string $subject + * @param string $message + * + * @return bool + */ + private static function sendEmail($admin_email, $subject, $message) + { + return wp_mail($admin_email, $subject, $message); + } +} diff --git a/lib/Cleantalk/ApbctWP/RemoteCalls.php b/lib/Cleantalk/ApbctWP/RemoteCalls.php index eccdff050..cd331a60e 100644 --- a/lib/Cleantalk/ApbctWP/RemoteCalls.php +++ b/lib/Cleantalk/ApbctWP/RemoteCalls.php @@ -2,6 +2,7 @@ namespace Cleantalk\ApbctWP; +use Cleantalk\ApbctWP\Firewall\FirewallBypass; use Cleantalk\ApbctWP\Firewall\SFWUpdateHelper; use Cleantalk\ApbctWP\RateLimit\ApbctRateLimiter; use Cleantalk\ApbctWP\Variables\Post; @@ -716,6 +717,23 @@ public static function action__get_fresh_wpnonce() // phpcs:ignore PSR1.Methods. ); } + /** + * Remote call: generates a one-time firewall bypass link and emails it to the site admin. + * + * @return void Dies with 'OK' or with 'FAIL {"error":"..."}'. + * @psalm-suppress PossiblyUnusedMethod + */ + public static function action__send_fw_bypass_email() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps + { + $result = FirewallBypass::processGenerationRemoteCall(); + if ( ! $result ) { + $error_message = FirewallBypass::$last_error ?? 'Unknown error'; + die('FAIL ' . json_encode(['error' => $error_message])); + } + + die('OK'); + } + private static function isRcAllowed() { global $apbct; diff --git a/lib/Cleantalk/ApbctWP/ServerRequirementsChecker/ServerRequirementsChecker.php b/lib/Cleantalk/ApbctWP/ServerRequirementsChecker/ServerRequirementsChecker.php index 6c36a1fd3..6c4db633f 100644 --- a/lib/Cleantalk/ApbctWP/ServerRequirementsChecker/ServerRequirementsChecker.php +++ b/lib/Cleantalk/ApbctWP/ServerRequirementsChecker/ServerRequirementsChecker.php @@ -5,7 +5,7 @@ class ServerRequirementsChecker { public $requirements = [ - 'php_version' => '5.6', + 'php_version' => '7.2', 'curl_support' => true, 'allow_url_fopen' => true, 'memory_limit' => '128M', diff --git a/lib/Cleantalk/ApbctWP/State.php b/lib/Cleantalk/ApbctWP/State.php index 92b1cbd0d..edb0f7985 100644 --- a/lib/Cleantalk/ApbctWP/State.php +++ b/lib/Cleantalk/ApbctWP/State.php @@ -316,6 +316,7 @@ class State extends \Cleantalk\Common\State 'rest_check' => array('last_call' => 0,), // WP nonce gathering 'get_fresh_wpnonce' => array('last_call' => 0,), + 'send_fw_bypass_email' => array('last_call' => 0, 'cooldown' => 60), ); /** diff --git a/tests/ApbctWP/FindSpam/ListTable/TestComments.php b/tests/ApbctWP/FindSpam/ListTable/TestComments.php index 52ee273fb..85da300bb 100644 --- a/tests/ApbctWP/FindSpam/ListTable/TestComments.php +++ b/tests/ApbctWP/FindSpam/ListTable/TestComments.php @@ -8,7 +8,7 @@ /** * Tests for Comments list table column_ct_author method. */ -class TestComments extends TestCase +class TestComments extends \ApbctTestCase { /** * @var Comments @@ -23,18 +23,15 @@ class TestComments extends TestCase protected function setUp(): void { global $apbct; - + $reflection = new \ReflectionClass(Comments::class); $this->instance = $reflection->newInstanceWithoutConstructor(); $this->columnCtAuthor = $reflection->getMethod('column_ct_author'); $this->columnCtAuthor->setAccessible(true); - // Set both instance property and global variable - $apbct = (object)[ - 'white_label' => true, - ]; - + $apbct->white_label = true; + // Use reflection to set the protected property $apbctProperty = $reflection->getProperty('apbct'); $apbctProperty->setAccessible(true); @@ -105,7 +102,7 @@ public function testColumnCtAuthorShowsNoIpWhenEmpty(): void public function testColumnCtAuthorShowsEmailWithoutCleantalkLinkWhenWhiteLabel(): void { global $apbct; - + $comment = (object)[ 'comment_author' => 'Test Author', 'comment_author_email' => 'test@example.com', @@ -125,10 +122,10 @@ public function testColumnCtAuthorShowsEmailWithoutCleantalkLinkWhenWhiteLabel() public function testColumnCtAuthorShowsEmailWithCleantalkLinkWhenNotWhiteLabel(): void { global $apbct; - + // Update global apbct $apbct->white_label = false; - + // Update instance apbct using reflection $reflection = new \ReflectionClass(Comments::class); $apbctProperty = $reflection->getProperty('apbct'); diff --git a/tests/ApbctWP/FindSpam/ListTable/TestUsers.php b/tests/ApbctWP/FindSpam/ListTable/TestUsers.php index 95a25a2e2..4d24bf5c2 100644 --- a/tests/ApbctWP/FindSpam/ListTable/TestUsers.php +++ b/tests/ApbctWP/FindSpam/ListTable/TestUsers.php @@ -1,154 +1,152 @@ -instance = $reflection->newInstanceWithoutConstructor(); - - $this->columnCtUsername = $reflection->getMethod('column_ct_username'); - $this->columnCtUsername->setAccessible(true); - - // Mock apbct: white_label and login_ip_keeper (object with getIP method) - $ipKeeper = $this->getMockBuilder(\stdClass::class) - ->addMethods(['getIP']) - ->getMock(); - $ipKeeper->method('getIP')->willReturn(null); - - // Set both instance property and global variable - $apbct = (object)[ - 'white_label' => true, - 'login_ip_keeper' => $ipKeeper, - ]; - - // Use reflection to set the protected property - $apbctProperty = $reflection->getProperty('apbct'); - $apbctProperty->setAccessible(true); - $apbctProperty->setValue($this->instance, $apbct); - } - - /** - * column_ct_username contains user login and row actions with nonce. - */ - public function testColumnCtUsernameContainsLoginAndActionsWithNonce(): void - { - $_GET['page'] = 'ct_check_users'; - - $user = (object)[ - 'ID' => 42, - 'user_login' => 'testuser', - 'user_email' => 'test@example.com', - ]; - $item = ['ct_username' => $user]; - - $result = $this->columnCtUsername->invoke($this->instance, $item); - - $this->assertStringContainsString('testuser', $result); - $this->assertStringContainsString('test@example.com', $result); - $this->assertStringContainsString('mailto:test@example.com', $result); - $this->assertStringContainsString('Approve', $result); - $this->assertStringContainsString('Delete', $result); - $this->assertStringContainsString('_wpnonce=', $result); - $this->assertStringContainsString('action=approve', $result); - $this->assertStringContainsString('action=delete', $result); - $this->assertStringContainsString('spam=42', $result); - } - - /** - * column_ct_username shows "No email" when user has no email. - */ - public function testColumnCtUsernameShowsNoEmailWhenEmpty(): void - { - $_GET['page'] = 'ct_check_users'; - - $user = (object)[ - 'ID' => 1, - 'user_login' => 'noemailuser', - 'user_email' => '', - ]; - $item = ['ct_username' => $user]; - - $result = $this->columnCtUsername->invoke($this->instance, $item); - - $this->assertStringContainsString('noemailuser', $result); - $this->assertStringContainsString('No email', $result); - } - - /** - * column_ct_username shows IP and link when login_ip_keeper returns IP. - */ - public function testColumnCtUsernameShowsIpWhenKeeperReturnsIp(): void - { - global $apbct; - - $_GET['page'] = 'ct_check_users'; - - $ipKeeper = $this->getMockBuilder(\stdClass::class) - ->addMethods(['getIP']) - ->getMock(); - $ipKeeper->method('getIP')->with(99)->willReturn('192.168.1.1'); - - // Update global apbct - $apbct->login_ip_keeper = $ipKeeper; - - // Update instance apbct using reflection - $reflection = new \ReflectionClass(Users::class); - $apbctProperty = $reflection->getProperty('apbct'); - $apbctProperty->setAccessible(true); - $instanceApbct = $apbctProperty->getValue($this->instance); - $instanceApbct->login_ip_keeper = $ipKeeper; - $apbctProperty->setValue($this->instance, $instanceApbct); - - $user = (object)[ - 'ID' => 99, - 'user_login' => 'ipuser', - 'user_email' => 'ip@test.com', - ]; - $item = ['ct_username' => $user]; - - $result = $this->columnCtUsername->invoke($this->instance, $item); - - $this->assertStringContainsString('192.168.1.1', $result); - $this->assertStringContainsString('user-edit.php?user_id=99', $result); - } - - /** - * column_ct_username shows "No IP adress" when keeper returns null. - */ - public function testColumnCtUsernameShowsNoIpWhenKeeperReturnsNull(): void - { - $_GET['page'] = 'ct_check_users'; - - $user = (object)[ - 'ID' => 1, - 'user_login' => 'noipuser', - 'user_email' => 'a@b.com', - ]; - $item = ['ct_username' => $user]; - - $result = $this->columnCtUsername->invoke($this->instance, $item); - - $this->assertStringContainsString('No IP adress', $result); - } -} +instance = $reflection->newInstanceWithoutConstructor(); + + $this->columnCtUsername = $reflection->getMethod('column_ct_username'); + $this->columnCtUsername->setAccessible(true); + + // Mock apbct: white_label and login_ip_keeper (object with getIP method) + $ipKeeper = $this->getMockBuilder(\stdClass::class) + ->addMethods(['getIP']) + ->getMock(); + $ipKeeper->method('getIP')->willReturn(null); + + // Set both instance property and global variable + $apbct->white_label = true; + $apbct->login_ip_keeper = $ipKeeper; + + // Use reflection to set the protected property + $apbctProperty = $reflection->getProperty('apbct'); + $apbctProperty->setAccessible(true); + $apbctProperty->setValue($this->instance, $apbct); + } + + /** + * column_ct_username contains user login and row actions with nonce. + */ + public function testColumnCtUsernameContainsLoginAndActionsWithNonce(): void + { + $_GET['page'] = 'ct_check_users'; + + $user = (object)[ + 'ID' => 42, + 'user_login' => 'testuser', + 'user_email' => 'test@example.com', + ]; + $item = ['ct_username' => $user]; + + $result = $this->columnCtUsername->invoke($this->instance, $item); + + $this->assertStringContainsString('testuser', $result); + $this->assertStringContainsString('test@example.com', $result); + $this->assertStringContainsString('mailto:test@example.com', $result); + $this->assertStringContainsString('Approve', $result); + $this->assertStringContainsString('Delete', $result); + $this->assertStringContainsString('_wpnonce=', $result); + $this->assertStringContainsString('action=approve', $result); + $this->assertStringContainsString('action=delete', $result); + $this->assertStringContainsString('spam=42', $result); + } + + /** + * column_ct_username shows "No email" when user has no email. + */ + public function testColumnCtUsernameShowsNoEmailWhenEmpty(): void + { + $_GET['page'] = 'ct_check_users'; + + $user = (object)[ + 'ID' => 1, + 'user_login' => 'noemailuser', + 'user_email' => '', + ]; + $item = ['ct_username' => $user]; + + $result = $this->columnCtUsername->invoke($this->instance, $item); + + $this->assertStringContainsString('noemailuser', $result); + $this->assertStringContainsString('No email', $result); + } + + /** + * column_ct_username shows IP and link when login_ip_keeper returns IP. + */ + public function testColumnCtUsernameShowsIpWhenKeeperReturnsIp(): void + { + global $apbct; + + $_GET['page'] = 'ct_check_users'; + + $ipKeeper = $this->getMockBuilder(\stdClass::class) + ->addMethods(['getIP']) + ->getMock(); + $ipKeeper->method('getIP')->with(99)->willReturn('192.168.1.1'); + + // Update global apbct + $apbct->login_ip_keeper = $ipKeeper; + + // Update instance apbct using reflection + $reflection = new \ReflectionClass(Users::class); + $apbctProperty = $reflection->getProperty('apbct'); + $apbctProperty->setAccessible(true); + $instanceApbct = $apbctProperty->getValue($this->instance); + $instanceApbct->login_ip_keeper = $ipKeeper; + $apbctProperty->setValue($this->instance, $instanceApbct); + + $user = (object)[ + 'ID' => 99, + 'user_login' => 'ipuser', + 'user_email' => 'ip@test.com', + ]; + $item = ['ct_username' => $user]; + + $result = $this->columnCtUsername->invoke($this->instance, $item); + + $this->assertStringContainsString('192.168.1.1', $result); + $this->assertStringContainsString('user-edit.php?user_id=99', $result); + } + + /** + * column_ct_username shows "No IP adress" when keeper returns null. + */ + public function testColumnCtUsernameShowsNoIpWhenKeeperReturnsNull(): void + { + $_GET['page'] = 'ct_check_users'; + + $user = (object)[ + 'ID' => 1, + 'user_login' => 'noipuser', + 'user_email' => 'a@b.com', + ]; + $item = ['ct_username' => $user]; + + $result = $this->columnCtUsername->invoke($this->instance, $item); + + $this->assertStringContainsString('No IP adress', $result); + } +} diff --git a/tests/ApbctWP/FindSpam/ListTable/TestWcSpamOrdersListTable.php b/tests/ApbctWP/FindSpam/ListTable/TestWcSpamOrdersListTable.php index bf4e1e287..7a57708dd 100644 --- a/tests/ApbctWP/FindSpam/ListTable/TestWcSpamOrdersListTable.php +++ b/tests/ApbctWP/FindSpam/ListTable/TestWcSpamOrdersListTable.php @@ -28,7 +28,7 @@ class TestWcSpamOrdersListTable extends TestCase protected function setUp(): void { global $apbct; - + $reflection = new \ReflectionClass(WcSpamOrdersListTable::class); $this->instance = $reflection->newInstanceWithoutConstructor(); @@ -38,11 +38,8 @@ protected function setUp(): void $this->renderCustomerDetailsColumn = $reflection->getMethod('renderCustomerDetailsColumn'); $this->renderCustomerDetailsColumn->setAccessible(true); - // Set both instance property and global variable - $apbct = (object)[ - 'white_label' => true, - ]; - + $apbct->white_label = true; + // Use reflection to set the protected property $apbctProperty = $reflection->getProperty('apbct'); $apbctProperty->setAccessible(true); diff --git a/tests/ApbctWP/Firewall/FirewallBypassTest.php b/tests/ApbctWP/Firewall/FirewallBypassTest.php new file mode 100644 index 000000000..a775cef98 --- /dev/null +++ b/tests/ApbctWP/Firewall/FirewallBypassTest.php @@ -0,0 +1,460 @@ +wpdb = $wpdb; + + $reflection = new ReflectionClass(FirewallBypass::class); + $constants = $reflection->getConstants(); + $this->ready_token_key = $constants['GENERATION_TOKEN_TRANSIENT_KEY']; + $this->user_token_key = $constants['USER_TOKEN_TRANSIENT_KEY']; + $this->request_param = $constants['GENERATION_REQUEST_PARAM']; + $this->cookie_name = $constants['USER_COOKIE_NAME']; + + $this->original_rc_running = $apbct->rc_running; + $apbct->rc_running = false; + + $this->original_cookies_type = $apbct->data['cookies_type']; + + $this->resetEnvironment(); + reset_phpmailer_instance(); + } + + protected function tearDown(): void + { + global $apbct; + + $apbct->rc_running = $this->original_rc_running; + $apbct->data['cookies_type'] = $this->original_cookies_type; + + $this->resetEnvironment(); + reset_phpmailer_instance(); + FirewallBypass::$last_error = null; + + parent::tearDown(); + } + + /** + * Brings the request and the storage to the state of a clean anonymous visit. + * + * @return void + */ + private function resetEnvironment() + { + delete_transient($this->ready_token_key); + delete_transient($this->user_token_key); + + unset($_GET[$this->request_param], $_REQUEST[$this->request_param], $_COOKIE[$this->cookie_name]); + + $this->resetRequestCache(); + } + + /** + * The variable handlers cache the values, drop the cache between the request emulations. + * + * @return void + */ + private function resetRequestCache() + { + Request::getInstance()->variables = array(); + Get::getInstance()->variables = array(); + Cookie::getInstance()->variables = array(); + } + + /** + * Emulates the cookie sent by the browser. Null removes the cookie. + * + * @param string|array|null $value + * + * @return void + */ + private function emulateCookie($value = null) + { + if ( $value === null ) { + unset($_COOKIE[$this->cookie_name]); + } else { + $_COOKIE[$this->cookie_name] = $value; + } + + $this->resetRequestCache(); + } + + /** + * Emulates a request carrying the bypass parameter. + * + * @param string $token + * + * @return void + */ + private function emulateRequestWithToken($token) + { + $this->resetRequestCache(); + + $_GET[$this->request_param] = $token; + } + + /** + * Runs the generation remote call in the verified remote call context. + * + * @return bool + */ + private function runGenerationRemoteCall() + { + global $apbct; + + $apbct->rc_running = true; + $result = FirewallBypass::processGenerationRemoteCall(); + $apbct->rc_running = false; + + return $result; + } + + /** + * Extracts the one-time token from the last sent email. + * + * @return string + */ + private function getTokenFromSentEmail() + { + $mailer = tests_retrieve_phpmailer_instance(); + $sent = $mailer->get_sent(); + + $this->assertNotFalse($sent, 'The notification email has not been sent.'); + $this->assertRegExp('/' . preg_quote($this->request_param, '/') . '=[a-f0-9]{64}/', $sent->body); + + preg_match('/' . preg_quote($this->request_param, '/') . '=([a-f0-9]{64})/', $sent->body, $matches); + + return $matches[1]; + } + + /** + * The happy path of the generation: the token is stored and the link is emailed. + */ + public function testGenerationStoresTokenAndSendsEmail() + { + $this->assertTrue($this->runGenerationRemoteCall()); + + $stored_token = get_transient($this->ready_token_key); + $this->assertIsString($stored_token); + $this->assertSame($stored_token, $this->getTokenFromSentEmail(), 'The emailed token must match the stored one.'); + } + + /** + * The token must be a 256 bit random value and must differ from call to call. + */ + public function testGeneratedTokensAreStrongAndUnique() + { + $this->runGenerationRemoteCall(); + $first_token = get_transient($this->ready_token_key); + + $this->runGenerationRemoteCall(); + $second_token = get_transient($this->ready_token_key); + + $this->assertRegExp('/^[a-f0-9]{64}$/', $first_token); + $this->assertRegExp('/^[a-f0-9]{64}$/', $second_token); + $this->assertNotSame($first_token, $second_token); + } + + /** + * A token that could not be delivered must not stay usable. + */ + public function testTokenIsDroppedWhenEmailFails() + { + $fail_mail = static function () { + return false; + }; + + add_filter('pre_wp_mail', $fail_mail); + $result = $this->runGenerationRemoteCall(); + remove_filter('pre_wp_mail', $fail_mail); + + $this->assertFalse($result); + $this->assertFalse(get_transient($this->ready_token_key)); + $this->assertSame('Failed to send admin email.', FirewallBypass::$last_error); + } + + /** + * A regular request without the parameter must not consume the token nor grant anything. + */ + public function testRegularRequestDoesNotConsumeToken() + { + $this->runGenerationRemoteCall(); + $token = get_transient($this->ready_token_key); + + FirewallBypass::maybeSetUserToken(); + + $this->assertSame($token, get_transient($this->ready_token_key)); + $this->assertArrayNotHasKey($this->cookie_name, $_COOKIE); + $this->assertFalse(FirewallBypass::bypassByUserToken()); + } + + /** + * A wrong token must neither grant the bypass nor invalidate the pending one. + */ + public function testWrongTokenIsRejected() + { + $this->runGenerationRemoteCall(); + $token = get_transient($this->ready_token_key); + + $this->emulateRequestWithToken(str_repeat('0', 64)); + FirewallBypass::maybeSetUserToken(); + + $this->assertArrayNotHasKey($this->cookie_name, $_COOKIE); + $this->assertFalse(FirewallBypass::bypassByUserToken()); + $this->assertSame($token, get_transient($this->ready_token_key), 'A wrong guess must not burn the token.'); + } + + /** + * The valid link grants the bypass to the browser that has opened it. + */ + public function testValidTokenGrantsBypass() + { + $this->runGenerationRemoteCall(); + $token = get_transient($this->ready_token_key); + + $this->emulateRequestWithToken($token); + FirewallBypass::maybeSetUserToken(); + + $this->assertArrayHasKey($this->cookie_name, $_COOKIE); + $this->assertRegExp('/^[a-f0-9]{64}$/', $_COOKIE[$this->cookie_name]); + $this->assertTrue(FirewallBypass::bypassByUserToken(), 'The bypass must work within the activating request.'); + } + + /** + * The bypass must not depend on the plugin's cookie mode setting: reading and granting + * the secret must work identically whether the admin picked "native", "alternative" or + * "none" cookies mode. + * + * @dataProvider cookiesTypeProvider + */ + public function testValidTokenGrantsBypassRegardlessOfCookiesType($cookies_type) + { + global $apbct; + $apbct->data['cookies_type'] = $cookies_type; + + $this->runGenerationRemoteCall(); + $token = get_transient($this->ready_token_key); + + $this->emulateRequestWithToken($token); + FirewallBypass::maybeSetUserToken(); + + $this->assertArrayHasKey( + $this->cookie_name, + $_COOKIE, + "The bypass cookie must be set even when cookies_type is '{$cookies_type}'." + ); + $this->assertRegExp('/^[a-f0-9]{64}$/', $_COOKIE[$this->cookie_name]); + $this->assertTrue( + FirewallBypass::bypassByUserToken(), + "The bypass must work within the activating request when cookies_type is '{$cookies_type}'." + ); + } + + /** + * @return array> + */ + public function cookiesTypeProvider() + { + return array( + 'native' => array('native'), + 'alternative' => array('alternative'), + 'none' => array('none'), + ); + } + + /** + * The secret itself must never be persisted, only its hash. + */ + public function testOnlyHashOfSecretIsStored() + { + $this->runGenerationRemoteCall(); + $this->emulateRequestWithToken(get_transient($this->ready_token_key)); + FirewallBypass::maybeSetUserToken(); + + $secret = $_COOKIE[$this->cookie_name]; + $stored_hash = get_transient($this->user_token_key); + + $this->assertNotSame($secret, $stored_hash); + $this->assertSame(hash('sha256', $secret), $stored_hash); + } + + /** + * The link is single use, a replay must not grant a second bypass. + */ + public function testReadyTokenIsSingleUse() + { + $this->runGenerationRemoteCall(); + $token = get_transient($this->ready_token_key); + + $this->emulateRequestWithToken($token); + FirewallBypass::maybeSetUserToken(); + + $this->assertFalse(get_transient($this->ready_token_key), 'The token must be consumed on the first use.'); + + // An attacker replays the very same link from another browser. + $this->emulateCookie(null); + $this->emulateRequestWithToken($token); + FirewallBypass::maybeSetUserToken(); + + $this->assertArrayNotHasKey($this->cookie_name, $_COOKIE); + $this->assertFalse(FirewallBypass::bypassByUserToken()); + } + + /** + * A visitor without the secret must not be bypassed even while a bypass is active. + */ + public function testVisitorWithoutCookieIsDenied() + { + $this->runGenerationRemoteCall(); + $this->emulateRequestWithToken(get_transient($this->ready_token_key)); + FirewallBypass::maybeSetUserToken(); + + $this->emulateCookie(null); + + $this->assertFalse(FirewallBypass::bypassByUserToken()); + } + + /** + * Forged and malformed cookies must be rejected. + * + * @dataProvider invalidCookieProvider + * + * @param string $cookie_value + */ + public function testInvalidCookieIsDenied($cookie_value) + { + $this->runGenerationRemoteCall(); + $this->emulateRequestWithToken(get_transient($this->ready_token_key)); + FirewallBypass::maybeSetUserToken(); + + $this->emulateCookie($cookie_value); + + $this->assertFalse(FirewallBypass::bypassByUserToken()); + } + + /** + * @return array[] + */ + public function invalidCookieProvider() + { + return array( + 'well formed but wrong' => array(str_repeat('a', 64)), + 'empty' => array(''), + 'too short' => array('deadbeef'), + 'uppercase hex' => array(str_repeat('A', 64)), + 'path traversal' => array('../../../etc/passwd'), + 'sql injection' => array("' OR 1=1 -- "), + 'wildcard' => array('%'), + ); + } + + /** + * An array cookie must not break the check. + */ + public function testArrayCookieIsDenied() + { + $this->runGenerationRemoteCall(); + $this->emulateRequestWithToken(get_transient($this->ready_token_key)); + FirewallBypass::maybeSetUserToken(); + + $this->emulateCookie(array('injected')); + + $this->assertFalse(FirewallBypass::bypassByUserToken()); + } + + /** + * The bypass must stop working as soon as the server side record is gone. + */ + public function testBypassStopsWhenServerRecordExpires() + { + $this->runGenerationRemoteCall(); + $this->emulateRequestWithToken(get_transient($this->ready_token_key)); + FirewallBypass::maybeSetUserToken(); + + $this->assertTrue(FirewallBypass::bypassByUserToken()); + + // Emulates the expiration of the granted bypass. + delete_transient($this->user_token_key); + + $this->assertFalse(FirewallBypass::bypassByUserToken()); + } + + /** + * A newly granted bypass must revoke the previous one, only one bypass per site is allowed. + */ + public function testNewBypassRevokesThePreviousOne() + { + $this->runGenerationRemoteCall(); + $this->emulateRequestWithToken(get_transient($this->ready_token_key)); + FirewallBypass::maybeSetUserToken(); + $first_secret = $_COOKIE[$this->cookie_name]; + + $this->emulateCookie(null); + $this->runGenerationRemoteCall(); + $this->emulateRequestWithToken(get_transient($this->ready_token_key)); + FirewallBypass::maybeSetUserToken(); + $second_secret = $_COOKIE[$this->cookie_name]; + + $this->assertNotSame($first_secret, $second_secret); + + $this->emulateCookie($first_secret); + $this->assertFalse(FirewallBypass::bypassByUserToken(), 'The replaced secret must not work anymore.'); + + $this->emulateCookie($second_secret); + $this->assertTrue(FirewallBypass::bypassByUserToken()); + } +} diff --git a/tests/ApbctWP/TestRequirementsChecker.php b/tests/ApbctWP/TestRequirementsChecker.php index 30a560475..a03481c92 100644 --- a/tests/ApbctWP/TestRequirementsChecker.php +++ b/tests/ApbctWP/TestRequirementsChecker.php @@ -125,7 +125,7 @@ public function testCheckRequirementsWithOldPhpVersion() $warnings = $checker->checkRequirements(); $this->assertNotEmpty($warnings, 'Warnings should be returned for old PHP version.'); - $this->assertStringContainsString('PHP version must be at least 5.6', $warnings[0]); + $this->assertStringContainsString('PHP version must be at least 7.2', $warnings[0]); } public function testCheckRequirementsWithoutCurlSupport()