diff --git a/src/wp-includes/l10n.php b/src/wp-includes/l10n.php index 0f9dd0d4016a0..18f67432d3f5f 100644 --- a/src/wp-includes/l10n.php +++ b/src/wp-includes/l10n.php @@ -21,6 +21,7 @@ * always be filtered using the {@see 'locale'} hook. * * @since 1.5.0 + * @since 7.2.0 Non-string values are ignored. * * @global string $locale The current locale. * @global string $wp_local_package Locale code of the package. @@ -31,8 +32,18 @@ function get_locale() { global $locale, $wp_local_package; if ( isset( $locale ) ) { + if ( empty( $locale ) || ! is_string( $locale ) ) { + $locale = 'en_US'; + } + /** This filter is documented in wp-includes/l10n.php */ - return apply_filters( 'locale', $locale ); + $filtered_locale = apply_filters( 'locale', $locale ); + + if ( empty( $filtered_locale ) || ! is_string( $filtered_locale ) ) { + return $locale; + } + + return $filtered_locale; } if ( isset( $wp_local_package ) ) { @@ -66,18 +77,30 @@ function get_locale() { } } - if ( empty( $locale ) ) { + /* + * The value may have come from an option, a constant or a global, none of + * which guarantee a type. Callers are documented to receive a string. + */ + if ( empty( $locale ) || ! is_string( $locale ) ) { $locale = 'en_US'; } /** * Filters the locale ID of the WordPress installation. * + * A value that is not a non-empty string is ignored. + * * @since 1.5.0 * * @param string $locale The locale ID. */ - return apply_filters( 'locale', $locale ); + $filtered_locale = apply_filters( 'locale', $locale ); + + if ( empty( $filtered_locale ) || ! is_string( $filtered_locale ) ) { + return $locale; + } + + return $filtered_locale; } /** @@ -87,6 +110,7 @@ function get_locale() { * returned. Otherwise it returns the locale of get_locale(). * * @since 4.7.0 + * @since 7.2.0 A non-string `locale` user meta value is ignored. * * @param int|WP_User $user User's ID or a WP_User object. Defaults to current user. * @return string The locale of the user. @@ -106,15 +130,25 @@ function get_user_locale( $user = 0 ) { return get_locale(); } + /* + * WP_User has no `locale` property. Reading it runs + * get_user_meta( $user_id, 'locale', true ), so this is a read of untyped + * storage and the row may hold anything, including an array. + */ $locale = $user_object->locale; - return $locale ? $locale : get_locale(); + if ( empty( $locale ) || ! is_string( $locale ) ) { + return get_locale(); + } + + return $locale; } /** * Determines the current locale desired for the request. * * @since 5.0.0 + * @since 7.2.0 Non-string values are ignored. * * @global string $pagenow The filename of the current screen. * @global string $wp_local_package Locale code of the package. @@ -162,18 +196,26 @@ function determine_locale() { } } - if ( ! $determined_locale ) { + if ( empty( $determined_locale ) || ! is_string( $determined_locale ) ) { $determined_locale = get_locale(); } /** * Filters the locale for the current request. * + * A value that is not a non-empty string is ignored. + * * @since 5.0.0 * * @param string $determined_locale The locale. */ - return apply_filters( 'determine_locale', $determined_locale ); + $filtered_locale = apply_filters( 'determine_locale', $determined_locale ); + + if ( empty( $filtered_locale ) || ! is_string( $filtered_locale ) ) { + return $determined_locale; + } + + return $filtered_locale; } /** diff --git a/src/wp-includes/user.php b/src/wp-includes/user.php index 01b567a74d86d..07cc7af16a4c8 100644 --- a/src/wp-includes/user.php +++ b/src/wp-includes/user.php @@ -2169,6 +2169,7 @@ function validate_username( $username ) { * @since 5.3.0 The `user_activation_key` field can be passed to `$userdata`. * @since 5.3.0 The `spam` field can be passed to `$userdata` (Multisite only). * @since 5.9.0 The `meta_input` field can be passed to `$userdata` to allow addition of user meta data. + * @since 7.2.0 A non-string `locale` field is ignored. * * @global wpdb $wpdb WordPress database abstraction object. * @@ -2523,7 +2524,8 @@ function wp_insert_user( $userdata ) { $meta['show_admin_bar_front'] = empty( $userdata['show_admin_bar_front'] ) ? 'true' : $userdata['show_admin_bar_front']; - $meta['locale'] = $userdata['locale'] ?? ''; + // The row is read back by get_user_locale(), which is documented to return a string. + $meta['locale'] = isset( $userdata['locale'] ) && is_string( $userdata['locale'] ) ? $userdata['locale'] : ''; $compacted = compact( 'user_pass', 'user_nicename', 'user_email', 'user_url', 'user_registered', 'user_activation_key', 'display_name' ); $data = wp_unslash( $compacted ); diff --git a/tests/phpunit/tests/l10n/determineLocale.php b/tests/phpunit/tests/l10n/determineLocale.php index ca8c0bafeccb7..bbc6dbace4141 100644 --- a/tests/phpunit/tests/l10n/determineLocale.php +++ b/tests/phpunit/tests/l10n/determineLocale.php @@ -308,4 +308,150 @@ public function test_wp_local_package_global_installing() { wp_installing( true ); $this->assertSame( 'de_DE', determine_locale() ); } + + /** + * sanitize_locale_name() applies preg_replace(), which maps over an array + * subject and returns an array, so `wp-login.php?wp_lang[]=de_DE` reaches + * the return statement with an array. No authentication is needed. + * + * @dataProvider data_array_request_value + * + * @param array $value Array request value. + */ + public function test_wp_login_get_param_on_login_page_array( $value ) { + $GLOBALS['pagenow'] = 'wp-login.php'; + $_GET['wp_lang'] = $value; + + $this->assertSame( 'en_US', determine_locale() ); + } + + /** + * @dataProvider data_array_request_value + * + * @param array $value Array request value. + */ + public function test_wp_login_cookie_on_login_page_array( $value ) { + $GLOBALS['pagenow'] = 'wp-login.php'; + $_COOKIE['wp_lang'] = $value; + + $this->assertSame( 'en_US', determine_locale() ); + } + + /** + * @dataProvider data_array_request_value + * + * @param array $value Array request value. + */ + public function test_language_param_installing_array( $value ) { + $_REQUEST['language'] = $value; + wp_installing( true ); + + $this->assertSame( 'en_US', determine_locale() ); + } + + /** + * An array locale reaches WP_Textdomain_Registry::set(), which uses it as + * an array key and throws a TypeError, so translating any string for an + * unloaded text domain takes down the login page for an anonymous visitor. + */ + public function test_array_wp_lang_param_does_not_fatal_in_the_textdomain_registry() { + $GLOBALS['pagenow'] = 'wp-login.php'; + $_GET['wp_lang'] = array( 'de_DE' ); + + $this->assertSame( 'Some text', __( 'Some text', 'my-login-plugin' ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_array_request_value() { + // An empty array is falsy, so it never reaches sanitize_locale_name(). + return array( + 'a list' => array( array( 'de_DE' ) ), + 'a map' => array( array( 'lang' => 'de_DE' ) ), + ); + } + + /** + * The `$wp_local_package` global is untyped and only checked for truthiness. + * + * @dataProvider data_non_string_locale + * + * @param mixed $value Non-string value. + */ + public function test_wp_local_package_global_installing_non_string( $value ) { + $GLOBALS['wp_local_package'] = $value; + wp_installing( true ); + $this->assertSame( 'en_US', determine_locale() ); + } + + /** + * The `determine_locale` filter result is returned unchecked, unlike + * `pre_determine_locale`, which is guarded with is_string(). + * + * @dataProvider data_non_string_locale + * + * @param mixed $value Non-string value. + */ + public function test_ignores_a_non_string_determine_locale_filter( $value ) { + add_filter( + 'determine_locale', + static function () use ( $value ) { + return $value; + } + ); + + $this->assertSame( 'en_US', determine_locale() ); + } + + /** + * An array `locale` user meta row reaches determine_locale() through + * get_user_locale() on every admin request. + * + * @dataProvider data_non_string_user_locale_meta + * + * @param mixed $meta_value Value stored in the `locale` user meta row. + */ + public function test_returns_a_string_for_a_non_string_user_locale_meta( $meta_value ) { + set_current_screen( 'dashboard' ); + wp_set_current_user( self::$user_id ); + update_user_meta( self::$user_id, 'locale', $meta_value ); + + $this->assertSame( 'en_US', determine_locale() ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_non_string_locale() { + return array( + 'a list' => array( array( 'de_DE' ) ), + 'a map' => array( array( 'locale' => 'de_DE' ) ), + 'an empty array' => array( array() ), + 'an object' => array( new stdClass() ), + 'an integer' => array( 1234 ), + 'a float' => array( 1.5 ), + 'true' => array( true ), + ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_non_string_user_locale_meta() { + // Scalars survive the meta round trip as strings, so only arrays and + // objects can come back from get_user_meta() with the wrong type. + return array( + 'a list' => array( array( 'de_DE' ) ), + 'a map' => array( array( 'locale' => 'de_DE' ) ), + 'an empty array' => array( array() ), + 'an object' => array( new stdClass() ), + ); + } } diff --git a/tests/phpunit/tests/l10n/getLocale.php b/tests/phpunit/tests/l10n/getLocale.php index bebae56316b87..63e79b91991fb 100644 --- a/tests/phpunit/tests/l10n/getLocale.php +++ b/tests/phpunit/tests/l10n/getLocale.php @@ -90,4 +90,208 @@ public function test_should_respect_get_locale_filter() { public function filter_get_locale() { return 'foo'; } + + /** + * The `WPLANG` option is untyped storage. sanitize_option() rejects a + * non-string on the way in, but nothing checks it on the way out, so a row + * written by a direct database query survives to the return value: a + * non-empty array passes both `false !== $db_locale` and `empty( $locale )`. + * + * @group ms-excluded + * + * @dataProvider data_stored_non_string_locale + * + * @param mixed $value Non-string value. + */ + public function test_should_fall_back_on_en_US_for_a_non_string_option( $value ) { + global $locale; + $old_locale = $locale; + $locale = null; + + $this->write_raw_option_row( 'WPLANG', $value ); + + $found = get_locale(); + $locale = $old_locale; + + $this->assertSame( 'en_US', $found ); + } + + /** + * @group ms-required + * + * @dataProvider data_stored_non_string_locale + * + * @param mixed $value Non-string value. + */ + public function test_should_fall_back_on_en_US_for_a_non_string_site_option( $value ) { + global $locale, $wpdb; + $old_locale = $locale; + $locale = null; + + update_site_option( 'WPLANG', 'en_US' ); + $wpdb->update( + $wpdb->sitemeta, + array( 'meta_value' => maybe_serialize( $value ) ), + array( + 'site_id' => get_current_network_id(), + 'meta_key' => 'WPLANG', + ) + ); + wp_cache_flush(); + + $found = get_locale(); + $locale = $old_locale; + + $this->assertSame( 'en_US', $found ); + } + + /** + * The `$locale` global short-circuits the function before any of its own + * guards run. + * + * @dataProvider data_non_string_locale + * + * @param mixed $value Non-string value. + */ + public function test_should_fall_back_on_en_US_for_a_non_string_locale_global( $value ) { + global $locale; + $old_locale = $locale; + $locale = $value; + + $found = get_locale(); + $locale = $old_locale; + + $this->assertSame( 'en_US', $found ); + } + + /** + * The `locale` filter result is returned unchecked. + * + * @dataProvider data_non_string_locale + * + * @param mixed $value Non-string value. + */ + public function test_should_ignore_a_non_string_locale_filter( $value ) { + global $locale; + $old_locale = $locale; + $locale = null; + + add_filter( + 'locale', + static function () use ( $value ) { + return $value; + } + ); + + $found = get_locale(); + $locale = $old_locale; + + $this->assertSame( 'en_US', $found ); + } + + /** + * The `locale` filter also runs on the `$locale` global short-circuit path. + * + * @dataProvider data_non_string_locale + * + * @param mixed $value Non-string value. + */ + public function test_should_ignore_a_non_string_locale_filter_on_the_global_path( $value ) { + global $locale; + $old_locale = $locale; + $locale = 'es_ES'; + + add_filter( + 'locale', + static function () use ( $value ) { + return $value; + } + ); + + $found = get_locale(); + $locale = $old_locale; + + $this->assertSame( 'es_ES', $found ); + } + + /** + * The `option_WPLANG` filter runs after the option is read. + * + * @group ms-excluded + * + * @dataProvider data_non_string_locale + * + * @param mixed $value Non-string value. + */ + public function test_should_fall_back_on_en_US_for_a_non_string_option_filter( $value ) { + global $locale; + $old_locale = $locale; + $locale = null; + + add_filter( + 'option_WPLANG', + static function () use ( $value ) { + return $value; + } + ); + + $found = get_locale(); + $locale = $old_locale; + + $this->assertSame( 'en_US', $found ); + } + + /** + * Writes an option row without going through sanitize_option(), the way a + * migration or a direct database query would. + * + * @param string $option Option name. + * @param mixed $value Value to store. + */ + private function write_raw_option_row( $option, $value ) { + global $wpdb; + + $wpdb->replace( + $wpdb->options, + array( + 'option_name' => $option, + 'option_value' => maybe_serialize( $value ), + ) + ); + + wp_cache_flush(); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_non_string_locale() { + return array( + 'a list' => array( array( 'de_DE' ) ), + 'a map' => array( array( 'locale' => 'de_DE' ) ), + 'an empty array' => array( array() ), + 'an object' => array( new stdClass() ), + 'an integer' => array( 1234 ), + 'a float' => array( 1.5 ), + 'true' => array( true ), + ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_stored_non_string_locale() { + // Scalars survive the storage round trip as strings, so only arrays and + // objects can come back from an option with the wrong type. + return array( + 'a list' => array( array( 'de_DE' ) ), + 'a map' => array( array( 'locale' => 'de_DE' ) ), + 'an empty array' => array( array() ), + 'an object' => array( new stdClass() ), + ); + } } diff --git a/tests/phpunit/tests/l10n/getUserLocale.php b/tests/phpunit/tests/l10n/getUserLocale.php index e4eaf7a2601bc..6c494a3bbac22 100644 --- a/tests/phpunit/tests/l10n/getUserLocale.php +++ b/tests/phpunit/tests/l10n/getUserLocale.php @@ -146,4 +146,60 @@ public function test_user_id_argument_with_invalid_type() { $user_locale = get_user_locale( 'string' ); $this->assertSame( get_locale(), $user_locale ); } + + /** + * A `locale` user meta row holding an array is truthy, so the `empty()`-style + * guard passes it through and callers receive an array where the documented + * return type is a string. + * + * @dataProvider data_non_string_user_locale_meta + * + * @param mixed $meta_value Value stored in the `locale` user meta row. + */ + public function test_returns_site_locale_for_non_string_user_locale_meta( $meta_value ) { + set_current_screen( 'dashboard' ); + update_user_meta( self::$administrator_de_de, 'locale', $meta_value ); + + $this->assertSame( get_locale(), get_user_locale() ); + } + + /** + * @dataProvider data_non_string_user_locale_meta + * + * @param mixed $meta_value Value stored in the `locale` user meta row. + */ + public function test_returns_a_string_for_non_string_user_locale_meta( $meta_value ) { + set_current_screen( 'dashboard' ); + update_user_meta( self::$administrator_de_de, 'locale', $meta_value ); + + $this->assertIsString( get_user_locale() ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_non_string_user_locale_meta() { + // Scalars survive the meta round trip as strings, so only arrays and + // objects can come back from get_user_meta() with the wrong type. + return array( + 'a list' => array( array( 'de_DE' ) ), + 'a map' => array( array( 'locale' => 'de_DE' ) ), + 'an empty array' => array( array() ), + 'an object' => array( new stdClass() ), + ); + } + + /** + * An array locale reaches WP_Textdomain_Registry::set(), which uses it as an + * array key and throws a TypeError, so translating any string for an + * unloaded text domain takes down the request. + */ + public function test_array_user_locale_meta_does_not_fatal_in_the_textdomain_registry() { + set_current_screen( 'dashboard' ); + update_user_meta( self::$administrator_de_de, 'locale', array( 'de_DE' ) ); + + $this->assertSame( 'Some text', __( 'Some text', 'my-plugin' ) ); + } } diff --git a/tests/phpunit/tests/user.php b/tests/phpunit/tests/user.php index f600adbcb1164..a5a306a3e42ad 100644 --- a/tests/phpunit/tests/user.php +++ b/tests/phpunit/tests/user.php @@ -1296,6 +1296,48 @@ public function test_wp_insert_user_should_not_wipe_existing_password() { $this->assertNotEmpty( $user->user_pass ); } + /** + * wp_insert_user() stores `$userdata['locale']` as user meta with no type + * check, so a caller can plant a value that get_user_locale() later returns + * where a string is documented. + * + * @dataProvider data_non_string_locale + * + * @covers ::wp_insert_user + * @covers ::wp_update_user + * + * @param mixed $locale Non-string locale. + */ + public function test_wp_insert_user_should_ignore_a_non_string_locale( $locale ) { + $user_id = self::factory()->user->create( array( 'locale' => 'de_DE' ) ); + + wp_update_user( + array( + 'ID' => $user_id, + 'locale' => $locale, + ) + ); + + $this->assertSame( '', get_user_meta( $user_id, 'locale', true ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_non_string_locale() { + return array( + 'a list' => array( array( 'de_DE' ) ), + 'a map' => array( array( 'locale' => 'de_DE' ) ), + 'an empty array' => array( array() ), + 'an object' => array( new stdClass() ), + 'an integer' => array( 1234 ), + 'a float' => array( 1.5 ), + 'true' => array( true ), + ); + } + /** * @ticket 29696 */