diff --git a/.gitignore b/.gitignore index eff5a4137..8ba6b35f7 100644 --- a/.gitignore +++ b/.gitignore @@ -26,4 +26,5 @@ psalm.xml /lib/CleantalkSP/Common/FSWatcher/Storage/data/ /lib/CleantalkSP/Common/FSWatcher/logs/ /lib/CleantalkSP/Common/Helpers/composer.json -/tests/.phpunit.result.cache \ No newline at end of file +/tests/.phpunit.result.cache +/skills/ \ No newline at end of file diff --git a/changelog.txt b/changelog.txt index fb0a9627f..54afb8469 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,14 @@ == Changelog == += 2.191 Oct 22 2026 = +* Fix. FSW. Restriction of access to FSW, edits for wpms, restriction for file actions (#762) +* Fix. 2FA. Flow updates. (#761) +* Fix. Settings. Links updated. +* Fix. Cure. Fixed cure regex errors before writing file contents. +* Fix. Scanner. Cure. Skip cure stage if option is currently disabled in settings. +* Fix. Scanner. Now manual scan starts correctly even if automatic scan option is disabled. +* Fix. Code. Optimizing the parsing of approved hashes https://app.doboard.com/1/task/57727 + = 2.190 Oct 09 2026 = * New. Security. Lost-password interface is under rate limit. (#755) * Upd. BFP. Lost password interface protected. (#743) diff --git a/inc/spbc-admin.php b/inc/spbc-admin.php index 365d6edd2..ae2bd050d 100644 --- a/inc/spbc-admin.php +++ b/inc/spbc-admin.php @@ -840,7 +840,7 @@ function spbc_get_authorized_admins($direct_call = false) function spbc_action_shuffle_salts() { - spbc_check_ajax_referer('spbc_secret_nonce', 'security'); + spbc_check_ajax_referer('spbc_secret_nonce', 'security', true, true, true); global $spbc; $salts_array = array( diff --git a/inc/spbc-auth.php b/inc/spbc-auth.php index 1c60b3a99..9ecb2e58b 100644 --- a/inc/spbc-auth.php +++ b/inc/spbc-auth.php @@ -1,12 +1,14 @@ settings['2fa__enable'] ) { - if (Server::inUriCanonical('wp-login.php') && !empty($_POST)) { + // Registered by the POST field, not by URI: the login page could be renamed + if (isset($_POST['spbc_2fa'])) { add_action('init', 'spbc_2fa_rate_limit', 10, 1); } add_action('login_form_login', 'spbc_2fa__authenticate', 1); // Authenticate with Code add_action('login_form', 'spbc_2fa__show_field', 10); add_action('after_password_reset', 'spbc_2fa__2fa_app_replace_meta', 10, 1); + add_action('after_password_reset', 'spbc_2fa__user_rate_limiter_reset', 10, 1); + // A challenge issued for the old password must not complete the login + add_action('after_password_reset', 'spbc_2fa__challenge_revoke', 10, 1); // Profile page hook add_action('show_user_profile', 'spbc_2fa__SelfUserProfileEdit', 1); add_action('edit_user_profile', 'spbc_2fa__SelfUserProfileEdit', 1); @@ -74,7 +80,300 @@ */ function spbc_2fa__2fa_app_replace_meta(\WP_User $user) { - return delete_user_meta($user->ID, 'spbc_2fa_type') && delete_user_meta($user->ID, 'spbc_2fa_app_token'); + delete_user_meta($user->ID, 'spbc_2fa_type'); + delete_user_meta($user->ID, 'spbc_2fa_app_token'); + delete_user_meta($user->ID, 'spbc_2fa_last_step'); + + return get_user_meta($user->ID, 'spbc_2fa_type', true) === '' + && get_user_meta($user->ID, 'spbc_2fa_app_token', true) === ''; +} + +/** + * Issue a one-time token proving that the first factor (password) has been passed. + * Stored hashed in user meta (one record per user, overwritten on each login) and sent to the browser as HttpOnly cookie. + * + * @param \WP_User $user + * + * @return void + */ +function spbc_2fa__step_token_issue(\WP_User $user) +{ + $token = bin2hex(random_bytes(32)); + $expires = time() + SPBC_2FA_KEY_TTL; + + update_user_meta( + $user->ID, + 'spbc_2fa_step', + array( + 'hash' => hash('sha256', $token), + 'expires' => $expires, + 'fails' => 0, + ) + ); + + spbc_2fa__step_cookie_set($token, $expires); +} + +/** + * @param string $value + * @param int $expires + * + * @return void + */ +function spbc_2fa__step_cookie_set($value, $expires) +{ + if ( headers_sent() ) { + return; + } + + /** + * COOKIE_DOMAIN is false by default in WordPress, despite Psalm's string type + * @psalm-suppress RedundantCast + */ + $domain = defined('COOKIE_DOMAIN') ? (string) COOKIE_DOMAIN : ''; + + \CleantalkSP\Variables\Cookie::set( + 'spbc_2fa_step', + $value, + $expires, + '/', + $domain, + is_ssl(), + true, + 'Lax' + ); +} + +/** + * @return string Token from the request or empty string + */ +function spbc_2fa__step_token_get_from_request() +{ + /** + * $_COOKIE value may be an array (spbc_2fa_step[]=...), despite Psalm's string type + * @psalm-suppress RedundantCondition + */ + $token = isset($_COOKIE['spbc_2fa_step']) && is_string($_COOKIE['spbc_2fa_step']) + ? $_COOKIE['spbc_2fa_step'] + : ''; + + return preg_match('/^[a-f0-9]{64}$/', $token) ? $token : ''; +} + +/** + * Check that the current request carries a valid 2FA step token for the user. + * + * @param \WP_User $user + * + * @return bool + */ +function spbc_2fa__step_token_is_valid(\WP_User $user) +{ + $token = spbc_2fa__step_token_get_from_request(); + if ( $token === '' ) { + return false; + } + + $step = get_user_meta($user->ID, 'spbc_2fa_step', true); + if ( ! is_array($step) || empty($step['hash']) || empty($step['expires']) ) { + return false; + } + + if ( (int) $step['expires'] < time() || (int) $step['fails'] >= SPBC_2FA_MAX_ATTEMPTS ) { + delete_user_meta($user->ID, 'spbc_2fa_step'); + return false; + } + + return hash_equals((string) $step['hash'], hash('sha256', $token)); +} + +/** + * Atomically replace (or delete) the stored step token record, only if it still equals $old. + * Of concurrent requests holding the same record exactly one succeeds. + * + * @param \WP_User $user + * @param array $old Record read by this request + * @param array|null $new New record or null to delete + * + * @return bool + */ +function spbc_2fa__step_token_swap(\WP_User $user, array $old, $new = null) +{ + global $wpdb; + + if ( $new === null ) { + $result = $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$wpdb->usermeta} WHERE user_id = %d AND meta_key = %s AND meta_value = %s", + $user->ID, + 'spbc_2fa_step', + maybe_serialize($old) + ) + ); + } else { + $result = $wpdb->query( + $wpdb->prepare( + "UPDATE {$wpdb->usermeta} SET meta_value = %s WHERE user_id = %d AND meta_key = %s AND meta_value = %s", + maybe_serialize($new), + $user->ID, + 'spbc_2fa_step', + maybe_serialize($old) + ) + ); + } + + wp_cache_delete($user->ID, 'user_meta'); + + return (int) $result > 0; +} + +/** + * Atomically count a code attempt before the code check, so parallel requests can't exceed the per-token limit. + * + * @param \WP_User $user + * + * @return array|false Reserved record or false if the token was changed by a concurrent request + */ +function spbc_2fa__step_token_reserve_attempt(\WP_User $user) +{ + $step = get_user_meta($user->ID, 'spbc_2fa_step', true); + if ( ! is_array($step) ) { + return false; + } + + $reserved = $step; + $reserved['fails'] = (int) $step['fails'] + 1; + + return spbc_2fa__step_token_swap($user, $step, $reserved) ? $reserved : false; +} + +/** + * Atomically consume the token on the correct code. Only one of concurrent requests may log in. + * + * @param \WP_User $user + * @param array $reserved Record returned by spbc_2fa__step_token_reserve_attempt() + * + * @return bool + */ +function spbc_2fa__step_token_claim(\WP_User $user, array $reserved) +{ + return spbc_2fa__step_token_swap($user, $reserved, null); +} + +/** + * Handle a wrong code. Destroys the step token when attempts are exhausted. + * + * @param \WP_User $user + * @param array $reserved Record returned by spbc_2fa__step_token_reserve_attempt() + * + * @return bool True if the step token is still usable + */ +function spbc_2fa__step_token_after_fail(\WP_User $user, array $reserved) +{ + if ( (int) $reserved['fails'] < SPBC_2FA_MAX_ATTEMPTS ) { + return true; + } + + spbc_2fa__challenge_revoke($user); + spbc_2fa__step_cookie_set('', time() - 3600); + + return false; +} + +/** + * Revoke the pending 2FA challenge: the step token and the e-mail code. + * The password has to be entered again to get a new one. + * + * @param \WP_User $user + * + * @return void + */ +function spbc_2fa__challenge_revoke(\WP_User $user) +{ + global $spbc; + + delete_user_meta($user->ID, 'spbc_2fa_step'); + + if ( isset($spbc->data['2fa_keys'][ $user->user_login ]) ) { + unset($spbc->data['2fa_keys'][ $user->user_login ]); + $spbc->save('data'); + } +} + +/** + * Per-user limiter of 2FA code attempts. Not reset by a new password login and not bound to IP. + * + * @param \WP_User $user + * + * @return SpbcUserRateLimiter + */ +function spbc_2fa__user_rate_limiter(\WP_User $user) +{ + return new SpbcUserRateLimiter( + new RateLimiterConfig('2fa_user_attempts', SPBC_2FA_USER_MAX_ATTEMPTS, SPBC_2FA_USER_LOCK_PERIOD), + $user->ID + ); +} + +/** + * Unlock 2FA step after the password reset: the user has proved access to the account e-mail. + * + * @param \WP_User $user + * + * @return void + */ +function spbc_2fa__user_rate_limiter_reset(\WP_User $user) +{ + spbc_2fa__user_rate_limiter($user)->reset(); +} + +/** + * Check 2FA application (TOTP) code with replay protection. + * + * @param \WP_User $user + * @param string $secret + * @param string $code + * @param bool $is_reused Set to true if the code is correct but its time step was already used + * @param bool $mark_used Mark the time step as used right away. Pass false to call spbc_2fa__app_code_mark_used() + * later, after the step token is claimed + * + * @return bool + */ +function spbc_2fa__app_code_check(\WP_User $user, $secret, $code, &$is_reused = false, $mark_used = true) +{ + $ga = new GoogleAuthenticator(); + $time_step = (int) floor(time() / 30); + $is_reused = false; + + if ( ! hash_equals((string) $ga->getCode($secret, $time_step), (string) $code) ) { + return false; + } + + // Reject the code if it (or a newer one) was already used + $last_step = (int) get_user_meta($user->ID, 'spbc_2fa_last_step', true); + if ( $time_step <= $last_step ) { + $is_reused = true; + return false; + } + + if ( $mark_used ) { + spbc_2fa__app_code_mark_used($user); + } + + return true; +} + +/** + * Mark the current time step as used. If the step has changed since the check, the newer one is marked, + * which rejects the checked code as well. + * + * @param \WP_User $user + * + * @return void + */ +function spbc_2fa__app_code_mark_used(\WP_User $user) +{ + update_user_meta($user->ID, 'spbc_2fa_last_step', (int) floor(time() / 30)); } /** @@ -223,6 +522,7 @@ function spbc_authenticate($user, $username) if ( $type2fa !== '2fa_application' ) { spbc_2fa__send_mail($user); } + spbc_2fa__step_token_issue($user); $woocommerce_additional_query_string = ''; if ( spbc_is_plugin_active('woocommerce/woocommerce.php') && @@ -867,7 +1167,8 @@ function spbc_2fa__show_field() if ( $spbc_2fa_user ) { $user = spbc_get_user_by('login', rawurldecode($spbc_2fa_user)); - if ( ! $user ) { + // The code form is shown only to the one who has just passed the password check + if ( ! $user || ! spbc_2fa__step_token_is_valid($user) ) { return; } @@ -887,7 +1188,30 @@ function spbc_2fa__show_field() } // Error displaying - if ( isset($_GET['spbc_2fa_error']) ) { + if ( Get::getString('spbc_2fa_error') === '2' && $type2fa === '2fa_application' ) { + // Correct code, but its time step is already used + $json_flags = JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT; + echo TextPlateStatic::render( + ' + + ', + array( + 'title' => wp_json_encode(__('CODE ALREADY USED: ', 'security-malware-firewall'), $json_flags), + 'text' => wp_json_encode(__('Please, wait for the next code in your 2FA Code application and try again in 30 seconds.', 'security-malware-firewall'), $json_flags), + ) + ); + } elseif ( isset($_GET['spbc_2fa_error']) ) { echo '')); + $this->assertStringContainsString('textContent', $error_script); + $this->assertStringNotContainsString('innerHTML', $error_script); + } + + public function testReusedCodeMessageIsNotShownForEmailCode() + { + $_GET['spbc_2fa_user'] = $this->email_user->user_login; + $_GET['spbc_2fa_error'] = '2'; + $this->giveStepToken($this->email_user); + + list(, $output) = $this->captureOutput('spbc_2fa__show_field'); + + $this->assertStringNotContainsString('CODE ALREADY USED', $output); + } + + // ---------------------------------------------------------------- 2FA application setup / removal + + public function testReplaceMetaRemovesSecretEvenWithoutType() + { + delete_user_meta($this->app_user->ID, 'spbc_2fa_type'); + update_user_meta($this->app_user->ID, 'spbc_2fa_last_step', 123); + + $this->assertTrue(spbc_2fa__2fa_app_replace_meta($this->app_user)); + $this->assertSame('', get_user_meta($this->app_user->ID, 'spbc_2fa_app_token', true)); + $this->assertSame('', get_user_meta($this->app_user->ID, 'spbc_2fa_last_step', true)); + } + + public function testQrEndpointDoesNotExposeActivatedSecret() + { + $this->asAjaxUser($this->app_user); + + list($result, $output) = $this->captureOutput('spbc_2fa__Get2FAAppQrCode'); + + $this->assertSame('die', $result[0]); + $response = json_decode($output, true); + $this->assertFalse($response['success']); + $this->assertStringNotContainsString(self::SECRET, $output); + $this->assertSame(self::SECRET, get_user_meta($this->app_user->ID, 'spbc_2fa_app_token', true)); + } + + public function testQrEndpointIssuesFreshSecretOnEachSetup() + { + delete_user_meta($this->email_user->ID, 'spbc_2fa_type'); + $this->asAjaxUser($this->email_user); + + $secrets = array(); + for ( $i = 0; $i < 2; $i++ ) { + list(, $output) = $this->captureOutput('spbc_2fa__Get2FAAppQrCode'); + $response = json_decode($output, true); + + $this->assertTrue($response['success']); + $this->assertRegExp('/^[A-Z2-7]{32}$/', $response['data']['code']); + $this->assertSame($response['data']['code'], get_user_meta($this->email_user->ID, 'spbc_2fa_app_token', true)); + $secrets[] = $response['data']['code']; + } + + $this->assertNotSame($secrets[0], $secrets[1]); + } + + public function testQrEndpointRequiresNonce() + { + $this->asAjaxUser($this->email_user); + $_REQUEST['security'] = 'wrong'; + + list($result) = $this->captureOutput('spbc_2fa__Get2FAAppQrCode'); + + $this->assertSame(array('die', 403), $result); + $this->assertSame('', get_user_meta($this->email_user->ID, 'spbc_2fa_app_token', true)); + } + + public function testActivationCodeMarksTimeStepAsUsed() + { + $this->inOneTimeStep(function ($code) { + delete_user_meta($this->app_user->ID, 'spbc_2fa_type'); + $this->asAjaxUser($this->app_user); + $_POST['code'] = $code; + + list(, $output) = $this->captureOutput('spbc_2fa__Check2FAAppCode'); + + $this->assertTrue(json_decode($output, true)['success']); + $this->assertSame('2fa_application', get_user_meta($this->app_user->ID, 'spbc_2fa_type', true)); + $this->assertSame( + $this->timeStep(), + (int) get_user_meta($this->app_user->ID, 'spbc_2fa_last_step', true) + ); + + // The code used for activation can't be used to log in + $this->giveStepToken($this->app_user); + $this->assertCodeError($this->postCode($this->app_user->user_login, $code), 2, $this->app_user); + }); + } + + public function testEmailCodeIsSixDigits() + { + global $spbc; + + for ( $i = 0; $i < 20; $i++ ) { + spbc_2fa__key_generate_and_store($this->email_user); + $this->assertRegExp( + '/^[1-9]\d{5}$/', + (string) $spbc->data['2fa_keys'][$this->email_user->user_login]['val'] + ); + } + } +} diff --git a/tests/lib/CleantalkSP/SpbctWP/ActivatorNewBlogTest.php b/tests/lib/CleantalkSP/SpbctWP/ActivatorNewBlogTest.php new file mode 100644 index 000000000..f0eb0dccd --- /dev/null +++ b/tests/lib/CleantalkSP/SpbctWP/ActivatorNewBlogTest.php @@ -0,0 +1,52 @@ +assertFalse(has_action('wp_insert_site', array(Activator::class, 'actionNewBlog'))); + $this->assertSame(11, has_action('wp_initialize_site', array(Activator::class, 'actionNewBlog'))); + } + + /** + * @test + */ + public function testNewBlogGetsPluginDataOnNetworkActivePlugin() + { + if ( ! is_multisite() ) { + $this->markTestSkipped('Multisite only, run with WP_MULTISITE=1.'); + } + + // Core creates the blog tables here, the plugin must run later + $this->assertSame(10, has_action('wp_initialize_site', 'wp_initialize_site')); + + $plugin = 'security-malware-firewall/security-malware-firewall.php'; + $active_plugins_backup = get_site_option('active_sitewide_plugins'); + $active_plugins = is_array($active_plugins_backup) ? $active_plugins_backup : array(); + $active_plugins[$plugin] = time(); + update_site_option('active_sitewide_plugins', $active_plugins); + + $blog_id = wpmu_create_blog( + (string) get_network()->domain, + '/spbc-new-blog-' . uniqid() . '/', + 'SPBC new blog test', + 1 + ); + + try { + $this->assertIsInt($blog_id); + $this->assertNotFalse(get_blog_option($blog_id, 'spbc_data')); + } finally { + update_site_option('active_sitewide_plugins', $active_plugins_backup); + if (is_int($blog_id)) { + wp_delete_site($blog_id); + } + } + } +} diff --git a/tests/lib/CleantalkSP/SpbctWP/FSWatcher/AnalyzerViewFileTest.php b/tests/lib/CleantalkSP/SpbctWP/FSWatcher/AnalyzerViewFileTest.php new file mode 100644 index 000000000..81c7abe32 --- /dev/null +++ b/tests/lib/CleantalkSP/SpbctWP/FSWatcher/AnalyzerViewFileTest.php @@ -0,0 +1,185 @@ +storage_backup = Controller::$storage; + $this->user_backup = get_current_user_id(); + Controller::$storage = FileStorage::class; + wp_set_current_user(1); + } + + protected function tearDown(): void + { + foreach ($this->files_to_remove as $file) { + if (is_file($file)) { + unlink($file); + } + } + $this->files_to_remove = array(); + Controller::$storage = $this->storage_backup; + wp_set_current_user($this->user_backup); + $_POST = array(); + parent::tearDown(); + } + + /** + * Site-root wp-config.php must not be readable through the viewer by a non Super Admin. + * @test + */ + public function testWpConfigInDiffIsDeniedWithoutSuperAdmin() + { + $wp_config = $this->prepareWpConfig(); + $this->writeJournals(array($wp_config => '1000'), array($wp_config => '2000')); + wp_set_current_user(0); + + $result = $this->view($wp_config); + + $this->assertArrayHasKey('error', $result, 'wp-config.php contents must not be returned'); + $this->assertStringContainsString('Super Admin', $result['error']); + } + + /** + * The same file stays viewable for a Super Admin. + * @test + */ + public function testWpConfigInDiffIsReadableForSuperAdmin() + { + $wp_config = $this->prepareWpConfig(); + $this->writeJournals(array($wp_config => '1000'), array($wp_config => '2000')); + wp_set_current_user(1); + + $result = $this->view($wp_config); + + $this->assertArrayHasKey('data', $result); + $this->assertStringContainsString('spbc-fsw-test-marker', $result['data']); + } + + /** + * Legit case: ordinary changed file is viewable. + * @test + */ + public function testChangedFileIsReadable() + { + $file = $this->makeTempFile('ordinary'); + $this->writeJournals(array($file => '1000'), array($file => '2000')); + + $result = $this->view($file); + + $this->assertArrayHasKey('data', $result); + $this->assertStringContainsString('spbc-fsw-test-marker', $result['data']); + } + + /** + * A file that exists but is not part of the diff must be rejected. + * @test + */ + public function testFileOutOfDiffIsDenied() + { + $file = $this->makeTempFile('victim'); + $other = $this->makeTempFile('other'); + $this->writeJournals(array($other => '1000'), array($other => '2000')); + + $result = $this->view($file); + + $this->assertArrayHasKey('error', $result); + $this->assertStringContainsString('out of FSWatcher journals', $result['error']); + } + + /** + * Path that is only a substring of a journal line must not pass. + * @test + */ + public function testPathThatIsOnlySubstringOfJournalEntryIsDenied() + { + $file = $this->makeTempFile('victim'); + $longer = $file . '.bak'; + $this->writeJournals(array($longer => '1000'), array($longer => '2000')); + + $result = $this->view($file); + + $this->assertArrayHasKey('error', $result); + $this->assertStringContainsString('out of FSWatcher journals', $result['error']); + } + + private function prepareWpConfig() + { + $wp_config = ABSPATH . 'wp-config.php'; + if (file_exists($wp_config)) { + $this->markTestSkipped('A real wp-config.php exists in ABSPATH, it is not touched by tests.'); + } + file_put_contents($wp_config, "files_to_remove[] = $wp_config; + + return $wp_config; + } + + private function makeTempFile($name) + { + $path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'spbc-fsw-' . $name . '-' . uniqid() . '.php'; + file_put_contents($path, "files_to_remove[] = $path; + + return $path; + } + + private function writeJournals(array $first, array $second) + { + global $spbc; + $dir = FileStorage::getJournalsPath(); + $key_hash = md5($spbc->settings['spbc_key']); + foreach (array(self::FIRST_JOURNAL => $first, self::SECOND_JOURNAL => $second) as $id => $rows) { + $csv = ''; + foreach ($rows as $path => $mtime) { + $csv .= '"' . $path . '",' . $mtime . "\n"; + } + $journal = $dir . $id . '_' . $key_hash . FileStorage::STATUS_COMPLETED . '.csv.gz'; + $gz = gzopen($journal, 'w9'); + gzwrite($gz, $csv); + gzclose($gz); + $this->files_to_remove[] = $journal; + } + } + + /** + * @return array{data?: string, error?: string} + */ + private function view($path) + { + $_POST = wp_slash(array( + 'fswatcher_file_path' => $path, + 'fswatcher__first_date' => (string) self::FIRST_JOURNAL, + 'fswatcher__second_date' => (string) self::SECOND_JOURNAL, + )); + + try { + return array('data' => Analyzer::getViewFile()); + } catch (\Exception $e) { + return array('error' => $e->getMessage()); + } + } +} diff --git a/tests/lib/CleantalkSP/SpbctWP/G2FA/TestGoogleAuthenticator.php b/tests/lib/CleantalkSP/SpbctWP/G2FA/TestGoogleAuthenticator.php new file mode 100644 index 000000000..0c031a28f --- /dev/null +++ b/tests/lib/CleantalkSP/SpbctWP/G2FA/TestGoogleAuthenticator.php @@ -0,0 +1,38 @@ +assertSame('287082', $ga->getCode('GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ', 1)); + } + + public function testCodeIsSixDigits() + { + $ga = new GoogleAuthenticator(); + + $this->assertRegExp('/^\d{6}$/', $ga->getCode($ga->generateSecret())); + } + + public function testSecretIs160BitBase32() + { + $ga = new GoogleAuthenticator(); + + $secrets = array(); + for ( $i = 0; $i < 50; $i++ ) { + $secret = $ga->generateSecret(); + $this->assertRegExp('/^[A-Z2-7]{32}$/', $secret); + $secrets[] = $secret; + } + + $this->assertCount(50, array_unique($secrets)); + } +} diff --git a/tests/lib/CleantalkSP/SpbctWP/RateLimiter/TestSpbcUserRateLimiter.php b/tests/lib/CleantalkSP/SpbctWP/RateLimiter/TestSpbcUserRateLimiter.php new file mode 100644 index 000000000..b3cd41007 --- /dev/null +++ b/tests/lib/CleantalkSP/SpbctWP/RateLimiter/TestSpbcUserRateLimiter.php @@ -0,0 +1,151 @@ +createTable(SPBC_TBL_RATE_LIMITS); + $this->deleteRows(); + + $this->saved_server = $_SERVER; + $this->setRequest('10.0.0.1', 'PHPUnit'); + } + + protected function tearDown(): void + { + $this->deleteRows(); + $_SERVER = $this->saved_server; + $this->resetRequestCache(); + + parent::tearDown(); + } + + private function deleteRows() + { + global $wpdb; + $wpdb->query($wpdb->prepare('DELETE FROM ' . SPBC_TBL_RATE_LIMITS . ' WHERE type = %s', self::TYPE)); + } + + private function resetRequestCache() + { + Server::getInstance()->variables = array(); + \CleantalkSP\SpbctWP\Helpers\IP::getInstance()->ips_stored = array(); + } + + private function setRequest($ip, $ua) + { + $_SERVER['REMOTE_ADDR'] = $ip; + $_SERVER['HTTP_USER_AGENT'] = $ua; + $this->resetRequestCache(); + } + + private function limiter($user_id, $limit = 2) + { + return new SpbcUserRateLimiter(new RateLimiterConfig(self::TYPE, $limit, 60), $user_id); + } + + public function testLocksAfterLimit() + { + $this->assertTrue($this->limiter(1)->checkPassed()); + $this->assertTrue($this->limiter(1)->checkPassed()); + + $limiter = $this->limiter(1); + $this->assertFalse($limiter->checkPassed()); + $this->assertTrue($limiter->process_ok); + } + + public function testCounterIsPerUser() + { + for ( $i = 0; $i < 3; $i++ ) { + $this->limiter(1)->checkPassed(); + } + + $this->assertFalse($this->limiter(1)->checkPassed()); + $this->assertTrue($this->limiter(2)->checkPassed()); + } + + public function testIpChangeDoesNotResetCounter() + { + for ( $i = 1; $i <= 2; $i++ ) { + $this->setRequest('10.0.0.' . $i, 'PHPUnit'); + $this->assertTrue($this->limiter(1)->checkPassed()); + } + + $this->setRequest('10.0.0.3', 'PHPUnit'); + $this->assertFalse($this->limiter(1)->checkPassed()); + } + + public function testResetUnlocksOnlyGivenUser() + { + for ( $i = 0; $i < 3; $i++ ) { + $this->limiter(1)->checkPassed(); + $this->limiter(2)->checkPassed(); + } + + $this->limiter(1)->reset(); + + $this->assertTrue($this->limiter(1)->checkPassed()); + $this->assertFalse($this->limiter(2)->checkPassed()); + } + + public function hostileUserAgentProvider() + { + return array( + 'long ascii' => array(str_repeat('A', 1000)), + 'multibyte char split at 200' => array(str_repeat('A', 199) . "\xC3\xA9"), + 'invalid utf-8 byte' => array('Mozilla' . "\xFF\xFE"), + '4-byte utf-8 char' => array('Mozilla ' . "\xF0\x9F\x98\x80"), + 'long multibyte' => array(str_repeat("\xD0\x96", 500)), + ); + } + + /** + * An attacker-controlled UA must not fail the insert: the limiter fails open on DB errors. + * + * @dataProvider hostileUserAgentProvider + */ + public function testHostileUserAgentDoesNotBreakCounting($ua) + { + $this->assertLockedAfterOneAttempt($ua); + } + + /** + * @dataProvider hostileUserAgentProvider + */ + public function testHostileUserAgentDoesNotBreakCountingInStrictMode($ua) + { + global $wpdb; + + $sql_mode = $wpdb->get_var('SELECT @@SESSION.sql_mode'); + $wpdb->query("SET SESSION sql_mode = 'STRICT_ALL_TABLES'"); + + try { + $this->assertLockedAfterOneAttempt($ua); + } finally { + $wpdb->query($wpdb->prepare('SET SESSION sql_mode = %s', $sql_mode)); + } + } + + private function assertLockedAfterOneAttempt($ua) + { + $this->setRequest('10.0.0.1', $ua); + + $this->assertTrue($this->limiter(1, 1)->checkPassed()); + + $limiter = $this->limiter(1, 1); + $this->assertFalse($limiter->checkPassed(), 'limiter must not fail open on a hostile UA'); + $this->assertTrue($limiter->process_ok); + } +}