From 8069b056f1fa9c1250165d9ab98e0933ffac79af Mon Sep 17 00:00:00 2001 From: alexandergull Date: Wed, 16 Sep 2026 17:14:38 +0500 Subject: [PATCH] Upd. Firewall. Emergency bypass logic updated to token-via-email model. https://app.doboard.com/1/task/55234 --- inc/spbc-firewall.php | 22 +- inc/spbc-tools.php | 3 + .../SpbctWP/Firewall/FirewallBypass.php | 402 ++++++++++++++++ lib/CleantalkSP/SpbctWP/RemoteCalls.php | 18 + lib/CleantalkSP/SpbctWP/State.php | 1 + .../SpbctWP/Firewall/FirewallBypassTest.php | 451 ++++++++++++++++++ 6 files changed, 889 insertions(+), 8 deletions(-) create mode 100644 lib/CleantalkSP/SpbctWP/Firewall/FirewallBypass.php create mode 100644 tests/lib/CleantalkSP/SpbctWP/Firewall/FirewallBypassTest.php diff --git a/inc/spbc-firewall.php b/inc/spbc-firewall.php index 747e471a0..7fcc1a30f 100644 --- a/inc/spbc-firewall.php +++ b/inc/spbc-firewall.php @@ -259,19 +259,25 @@ function spbc_firewall_skip_check() } } + // Legacy bypass: keep the old IP/API-key cookie mechanism for the transition period. + $ip_set = IP::get(); + $ip_set = empty($ip_set) ? array() : $ip_set; + $ip_set = is_array($ip_set) ? $ip_set : array($ip_set); + foreach ( $ip_set as $spbc_cur_ip ) { + if ( Cookie::getString('spbc_firewall_pass_key') === md5($spbc_cur_ip . $spbc->settings['spbc_key']) ) { + return true; + } + } + // Turn off the SpamFireWall if Remote Call is in progress if ( ( ! empty($apbct) && $apbct->rc_running ) || $spbc->rc_running ) { return true; } - // Pass the check if cookie is set. - $ip_set = IP::get(); - $ip_set = empty($ip_set) ? [] : $ip_set; - $ip_set = is_array($ip_set) ? $ip_set : [$ip_set]; - foreach ( $ip_set as $spbc_cur_ip ) { - if ( Cookie::getString('spbc_firewall_pass_key') == md5($spbc_cur_ip . $spbc->settings['spbc_key']) ) { - return true; - } + // Emergency bypass: consume the one-time link from the admin email, then check the granted bypass. + Firewall\FirewallBypass::maybeSetUserToken(); + if (Firewall\FirewallBypass::bypassByUserToken()) { + return true; } return false; diff --git a/inc/spbc-tools.php b/inc/spbc-tools.php index 24522c63d..b232fbaed 100644 --- a/inc/spbc-tools.php +++ b/inc/spbc-tools.php @@ -1207,6 +1207,9 @@ function spbc_format_security_log_event($event, $url, $page_time = null, $show_p case 'logout': $formatted = __('Logout', 'security-malware-firewall'); break; + case 'fw_bypass': + $formatted = __('Firewall bypass activated', 'security-malware-firewall'); + break; default: $formatted = esc_html((string) $event); break; diff --git a/lib/CleantalkSP/SpbctWP/Firewall/FirewallBypass.php b/lib/CleantalkSP/SpbctWP/Firewall/FirewallBypass.php new file mode 100644 index 000000000..1c3445c04 --- /dev/null +++ b/lib/CleantalkSP/SpbctWP/Firewall/FirewallBypass.php @@ -0,0 +1,402 @@ +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); + self::logBypassActivation(); + } 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) + { + // Only the hash is persisted, so the storage dump does not allow to reproduce the cookie. + set_transient(self::USER_TOKEN_TRANSIENT_KEY, self::hashSecret($user_secret), self::USER_TOKEN_EXPIRATION); + + // setcookie() does not populate the superglobal, fill it to let the very same request pass. + $_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]); + + // 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. + if ( headers_sent() ) { + self::$last_error = 'Headers are already sent, the bypass cookie has not been set.'; + return; + } + + // The base class is used on purpose: the bypass must not depend on the plugin's cookie settings + // and must not be routed to the alternative sessions storage. + Cookie::set( + 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. + */ + private static function getUserSecretFromRequest() + { + $secret = Cookie::getString(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); + } + + /** + * Writes the bypass activation to the security log. Skipping the firewall is an auditable event. + * + * @return void + */ + private static function logBypassActivation() + { + // The firewall may run before the admin includes are loaded. + if ( ! function_exists('spbc_auth_log') ) { + return; + } + + spbc_auth_log( + array( + 'event' => 'fw_bypass', + 'page' => Server::getString('REQUEST_URI'), + 'user_agent' => htmlspecialchars(Server::getString('HTTP_USER_AGENT')), + ) + ); + } + + /** + * 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 spbc_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.", + 'security-malware-firewall' + ); + + 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 __('Security by CleanTalk plugin: Firewall Bypass Link Generated', 'security-malware-firewall'); + } + + /** + * 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/CleantalkSP/SpbctWP/RemoteCalls.php b/lib/CleantalkSP/SpbctWP/RemoteCalls.php index ccd577a22..db4af5f78 100644 --- a/lib/CleantalkSP/SpbctWP/RemoteCalls.php +++ b/lib/CleantalkSP/SpbctWP/RemoteCalls.php @@ -4,6 +4,7 @@ use CleantalkSP\Common\RateLimit\RateLimiterConfig; use CleantalkSP\SpbctWP\Cron as SpbcCron; +use CleantalkSP\SpbctWP\Firewall\FirewallBypass; use CleantalkSP\SpbctWP\Scanner\ScannerActions\BackupsActions; use CleantalkSP\SpbctWP\Scanner\ScannerAjaxEndpoints; use CleantalkSP\SpbctWP\Scanner\ScanningLog\Repository; @@ -698,6 +699,23 @@ public static function action__launch_background_scan() // phpcs:ignore PSR1.Met ); } + /** + * 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 hideSensitiveData($data) { if ( ! is_array($data) ) { diff --git a/lib/CleantalkSP/SpbctWP/State.php b/lib/CleantalkSP/SpbctWP/State.php index 3176d62ee..3bc7ddb59 100644 --- a/lib/CleantalkSP/SpbctWP/State.php +++ b/lib/CleantalkSP/SpbctWP/State.php @@ -282,6 +282,7 @@ class State extends \CleantalkSP\Common\State 'private_record_add' => array( 'last_call' => 0, 'cooldown' => 0), 'private_record_delete' => array( 'last_call' => 0, 'cooldown' => 0), 'update_pscan_statuses' => array('last_call' => 0, 'cooldown' => 0), + 'send_fw_bypass_email' => array('last_call' => 0, 'cooldown' => 60), // Inner 'download__quarantine_file' => array('last_call' => 0, 'cooldown' => 3), diff --git a/tests/lib/CleantalkSP/SpbctWP/Firewall/FirewallBypassTest.php b/tests/lib/CleantalkSP/SpbctWP/Firewall/FirewallBypassTest.php new file mode 100644 index 000000000..5fc03c8ee --- /dev/null +++ b/tests/lib/CleantalkSP/SpbctWP/Firewall/FirewallBypassTest.php @@ -0,0 +1,451 @@ +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']; + + // The security log is used to audit the activation. + $db_tables_creator = new \CleantalkSP\SpbctWP\DB\TablesCreator(); + $db_tables_creator->createTable(SPBC_TBL_SECURITY_LOG); + $this->wpdb->query('DELETE FROM ' . SPBC_TBL_SECURITY_LOG); + + // Enable the security log feature restriction. + $spbc->moderate = 1; + $spbc->key_is_ok = 1; + + $this->original_rc_running = $spbc->rc_running; + $spbc->rc_running = false; + + $this->resetEnvironment(); + reset_phpmailer_instance(); + } + + protected function tearDown(): void + { + global $spbc; + + $spbc->rc_running = $this->original_rc_running; + + $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) + { + $_GET[$this->request_param] = $token; + $_REQUEST[$this->request_param] = $token; + + $this->resetRequestCache(); + } + + /** + * Runs the generation remote call in the verified remote call context. + * + * @return bool + */ + private function runGenerationRemoteCall() + { + global $spbc; + + $spbc->rc_running = true; + $result = FirewallBypass::processGenerationRemoteCall(); + $spbc->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 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()); + } + + /** + * Skipping the firewall is an auditable event and must be logged. + */ + public function testActivationIsLogged() + { + $_SERVER['REQUEST_URI'] = '/?' . $this->request_param . '=test'; + \CleantalkSP\Variables\Server::getInstance()->variables = array(); + + $this->runGenerationRemoteCall(); + $this->emulateRequestWithToken(get_transient($this->ready_token_key)); + FirewallBypass::maybeSetUserToken(); + + $logged_events = $this->wpdb->get_col('SELECT event FROM ' . SPBC_TBL_SECURITY_LOG); + + $this->assertContains('fw_bypass', $logged_events); + } + + /** + * A rejected activation must leave no trace in the security log. + */ + public function testRejectedActivationIsNotLogged() + { + $this->runGenerationRemoteCall(); + + $this->emulateRequestWithToken(str_repeat('f', 64)); + FirewallBypass::maybeSetUserToken(); + + $count = (int)$this->wpdb->get_var('SELECT COUNT(*) FROM ' . SPBC_TBL_SECURITY_LOG); + + $this->assertSame(0, $count); + } +}