diff --git a/includes/admin/class-wpum-options-panel.php b/includes/admin/class-wpum-options-panel.php index 89115f6e..25760f2b 100644 --- a/includes/admin/class-wpum-options-panel.php +++ b/includes/admin/class-wpum-options-panel.php @@ -244,6 +244,12 @@ public function register_settings( $settings ) { 'value' => true, ), ), + array( + 'id' => 'generic_login_errors', + 'name' => __( 'Generic Login Error', 'wp-user-manager' ), + 'desc' => __( 'Replace specific login error messages with a generic message to prevent username enumeration.', 'wp-user-manager' ), + 'type' => 'checkbox', + ), ), 'misc' => array( array( diff --git a/includes/filters.php b/includes/filters.php index 4f78b049..32a70ad4 100644 --- a/includes/filters.php +++ b/includes/filters.php @@ -182,6 +182,45 @@ function wpum_authentication( $wp_user, $username, $password ) { } add_filter( 'authenticate', 'wpum_authentication', 20, 3 ); +/** + * Replace specific login error messages with a generic message + * to prevent username enumeration when the setting is enabled. + * + * @param WP_User|WP_Error $user + * @param string $username + * @param string $password + * + * @return WP_User|WP_Error + */ +function wpum_generic_login_errors( $user, $username, $password ) { + if ( ! is_wp_error( $user ) || empty( $username ) ) { + return $user; + } + + if ( ! wpum_get_option( 'generic_login_errors' ) ) { + return $user; + } + + $code = $user->get_error_code(); + + $enumeration_codes = array( + 'invalid_username', + 'invalid_email', + 'incorrect_password', + 'email_only', + ); + + if ( in_array( $code, $enumeration_codes, true ) ) { + return new WP_Error( + 'authentication_failed', + apply_filters( 'wpum_generic_login_error_message', __( 'Error: The username or password you entered is incorrect.', 'wp-user-manager' ) ) + ); + } + + return $user; +} +add_filter( 'authenticate', 'wpum_generic_login_errors', 50, 3 ); + /** * Highlight all pages used by WPUM. * diff --git a/tests/e2e/login.spec.ts b/tests/e2e/login.spec.ts index 2bcf711c..ac922612 100644 --- a/tests/e2e/login.spec.ts +++ b/tests/e2e/login.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from './fixtures'; +import { test, expect, wpCli } from './fixtures'; test.describe('Login Form', () => { test.beforeEach(async ({ page }) => { @@ -108,6 +108,37 @@ test.describe('Login Form', () => { await expect(page).toHaveURL(/wp-login\.php/); }); + test('generic login errors hides username enumeration', async ({ page, loginPage }) => { + wpCli(`eval 'wpum_update_option("generic_login_errors", true);'`); + + await page.goto(loginPage); + await page.locator('#username').fill('nonexistent_user_12345'); + await page.locator('#password').fill('WrongPassword123!'); + await page.locator('input[name="submit_login"]').click(); + await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {}); + + const errorMessage = page.locator('.wpum-message.error'); + await expect(errorMessage).toBeVisible({ timeout: 5000 }); + const errorText = await errorMessage.textContent(); + expect(errorText).toContain('username or password you entered is incorrect'); + expect(errorText).not.toContain('not registered'); + expect(errorText).not.toContain('Unknown'); + + await page.goto(loginPage); + await page.locator('#username').fill('testuser_login'); + await page.locator('#password').fill('WrongPassword123!'); + await page.locator('input[name="submit_login"]').click(); + await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {}); + + const errorMessage2 = page.locator('.wpum-message.error'); + await expect(errorMessage2).toBeVisible({ timeout: 5000 }); + const errorText2 = await errorMessage2.textContent(); + expect(errorText2).toContain('username or password you entered is incorrect'); + expect(errorText2).not.toContain('incorrect password'); + + wpCli(`eval 'wpum_update_option("generic_login_errors", false);'`); + }); + test('redirect after login', async ({ page, loginPage }) => { await page.goto(loginPage);