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);
+ }
+}