From 708d48ced7f35007b6e4d4c465b47c70199e827a Mon Sep 17 00:00:00 2001 From: vuckro Date: Wed, 10 Jun 2026 14:24:07 +0200 Subject: [PATCH 1/2] fix(security): use a high-entropy random key for security-mode disable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unauthenticated ?wu_secure=KEY query string that turns the network-wide recovery "security mode" off used substr(md5(admin_email), 0, 6) as the key — only ~24 bits and derived from a commonly public/guessable value, so an attacker could compute or brute-force it and remotely disable the admin's safe-mode lockdown. Generate a 128-bit random key once (random_bytes, since this runs from sunrise before pluggable.php) and store it as a network option, and compare it with hash_equals(). The key is already displayed on the settings screen, so the documented "copy this URL to disable security mode" workflow is unaffected. Co-Authored-By: Claude Fable 5 --- inc/class-sunrise.php | 2 +- inc/functions/sunrise.php | 22 +++++++++++++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/inc/class-sunrise.php b/inc/class-sunrise.php index 919f4be52..a789ec8b4 100644 --- a/inc/class-sunrise.php +++ b/inc/class-sunrise.php @@ -335,7 +335,7 @@ 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 + if (hash_equals(wu_get_security_mode_key(), (string) wu_get_isset($_GET, 'wu_secure'))) { // phpcs:ignore WordPress.Security.NonceVerification wu_save_setting_early('security_mode', false); } else { /** diff --git a/inc/functions/sunrise.php b/inc/functions/sunrise.php index d016695a2..04f8aed40 100644 --- a/inc/functions/sunrise.php +++ b/inc/functions/sunrise.php @@ -72,15 +72,31 @@ 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. random_bytes() is used (not + * wp_generate_password) because this runs from sunrise, before pluggable.php is + * loaded. The current key is shown to admins on the settings screen, so rotating + * it is transparent for the documented copy-the-URL workflow. * * @since 2.0.20 */ function wu_get_security_mode_key(): string { - $hash = md5((string) get_network_option(null, 'admin_email')); + $key = (string) get_network_option(null, 'wu_security_mode_key', ''); + + if ('' === $key) { + $key = bin2hex(random_bytes(16)); + + update_network_option(null, 'wu_security_mode_key', $key); + } - return substr($hash, 0, 6); + return $key; } /** From 1580eaf4b560a3cd3be93def8807f92edaae32b0 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 10 Jun 2026 07:51:48 -0600 Subject: [PATCH 2/2] fix(security): preserve legacy security mode recovery URL --- inc/class-sunrise.php | 4 ++- inc/functions/sunrise.php | 33 ++++++++++++++++--- .../Functions/Sunrise_Functions_Test.php | 32 ++++++++++++++---- 3 files changed, 57 insertions(+), 12 deletions(-) diff --git a/inc/class-sunrise.php b/inc/class-sunrise.php index a789ec8b4..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 (hash_equals(wu_get_security_mode_key(), (string) wu_get_isset($_GET, 'wu_secure'))) { // 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 04f8aed40..08e3e3b3c 100644 --- a/inc/functions/sunrise.php +++ b/inc/functions/sunrise.php @@ -79,18 +79,27 @@ function wu_save_setting_early($key, $value) { * 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. random_bytes() is used (not - * wp_generate_password) because this runs from sunrise, before pluggable.php is - * loaded. The current key is shown to admins on the settings screen, so rotating - * it is transparent for the documented copy-the-URL workflow. + * 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(): 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); @@ -99,6 +108,20 @@ function wu_get_security_mode_key(): string { return $key; } +/** + * Get the legacy security mode key used before high-entropy keys were persisted. + * + * @since 2.0.20 + * + * @return string + */ +function wu_get_legacy_security_mode_key(): string { + + $hash = md5((string) get_network_option(null, 'admin_email')); + + return substr($hash, 0, 6); +} + /** * Early substitute for wp_kses_data before it exists. * 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. */