diff --git a/inc/class-sunrise.php b/inc/class-sunrise.php index 919f4be52..1e98bb74d 100644 --- a/inc/class-sunrise.php +++ b/inc/class-sunrise.php @@ -335,7 +335,9 @@ public static function load(): void { $security_mode = (bool) (int) wu_get_setting_early('security_mode'); if ($security_mode) { - if (wu_get_isset($_GET, 'wu_secure') === wu_get_security_mode_key()) { // phpcs:ignore WordPress.Security.NonceVerification + $provided_key = wu_get_isset($_GET, 'wu_secure'); // phpcs:ignore WordPress.Security.NonceVerification + + if (is_string($provided_key) && hash_equals(wu_get_security_mode_key(false), $provided_key)) { wu_save_setting_early('security_mode', false); } else { /** diff --git a/inc/functions/sunrise.php b/inc/functions/sunrise.php index d016695a2..08e3e3b3c 100644 --- a/inc/functions/sunrise.php +++ b/inc/functions/sunrise.php @@ -72,11 +72,50 @@ function wu_save_setting_early($key, $value) { } /** - * Get the security mode key used to disable security mode + * Get the security mode key used to disable security mode. + * + * This key is exposed in an unauthenticated query string (?wu_secure=KEY) that + * turns the network-wide recovery "security mode" off, so it must be + * unpredictable. It used to be substr(md5(admin_email), 0, 6) — only ~24 bits + * and derived from a frequently public/guessable value, which an attacker could + * compute or brute-force. We now use a high-entropy random secret generated once + * and stored as a network option when the key is displayed to admins. + * random_bytes() is used (not wp_generate_password) because this runs from + * sunrise, before pluggable.php is loaded. Sunrise validation does not generate + * a new key while security mode is already active; if a random key has not been + * persisted yet, the legacy derived key remains valid until an admin loads the + * settings screen and sees the new random recovery URL. * * @since 2.0.20 + * + * @param bool $generate Whether to generate and persist a random key when missing. + * @return string + */ +function wu_get_security_mode_key($generate = true): string { + + $key = (string) get_network_option(null, 'wu_security_mode_key', ''); + + if ('' === $key) { + if (! $generate) { + return wu_get_legacy_security_mode_key(); + } + + $key = bin2hex(random_bytes(16)); + + update_network_option(null, 'wu_security_mode_key', $key); + } + + return $key; +} + +/** + * Get the legacy security mode key used before high-entropy keys were persisted. + * + * @since 2.0.20 + * + * @return string */ -function wu_get_security_mode_key(): string { +function wu_get_legacy_security_mode_key(): string { $hash = md5((string) get_network_option(null, 'admin_email')); diff --git a/tests/WP_Ultimo/Functions/Sunrise_Functions_Test.php b/tests/WP_Ultimo/Functions/Sunrise_Functions_Test.php index f20f15121..573272972 100644 --- a/tests/WP_Ultimo/Functions/Sunrise_Functions_Test.php +++ b/tests/WP_Ultimo/Functions/Sunrise_Functions_Test.php @@ -101,14 +101,16 @@ public function test_save_setting_early_stores_value(): void { } /** - * Test wu_get_security_mode_key returns a 6-character string. + * Test wu_get_security_mode_key returns a high-entropy string. */ - public function test_get_security_mode_key_returns_six_char_string(): void { + public function test_get_security_mode_key_returns_high_entropy_string(): void { + + delete_network_option(null, 'wu_security_mode_key'); $key = wu_get_security_mode_key(); $this->assertIsString($key); - $this->assertSame(6, strlen($key)); + $this->assertSame(32, strlen($key)); } /** @@ -116,15 +118,19 @@ public function test_get_security_mode_key_returns_six_char_string(): void { */ public function test_get_security_mode_key_returns_hex_characters(): void { + delete_network_option(null, 'wu_security_mode_key'); + $key = wu_get_security_mode_key(); - $this->assertMatchesRegularExpression('/^[0-9a-f]{6}$/', $key); + $this->assertMatchesRegularExpression('/^[0-9a-f]{32}$/', $key); } /** - * Test wu_get_security_mode_key is deterministic for same admin email. + * Test wu_get_security_mode_key is stable after generation. */ - public function test_get_security_mode_key_is_deterministic(): void { + public function test_get_security_mode_key_is_stable_after_generation(): void { + + delete_network_option(null, 'wu_security_mode_key'); $key1 = wu_get_security_mode_key(); $key2 = wu_get_security_mode_key(); @@ -132,6 +138,20 @@ public function test_get_security_mode_key_is_deterministic(): void { $this->assertSame($key1, $key2); } + /** + * Test wu_get_security_mode_key can preserve the legacy key without rotating. + */ + public function test_get_security_mode_key_without_generation_returns_legacy_key(): void { + + delete_network_option(null, 'wu_security_mode_key'); + + $expected = substr(md5((string) get_network_option(null, 'admin_email')), 0, 6); + $key = wu_get_security_mode_key(false); + + $this->assertSame($expected, $key); + $this->assertSame('', get_network_option(null, 'wu_security_mode_key', '')); + } + /** * Test wu_kses_data returns string. */