diff --git a/src/wp-includes/class-wp-xmlrpc-server.php b/src/wp-includes/class-wp-xmlrpc-server.php index 4ac680065f036..9416427810c46 100644 --- a/src/wp-includes/class-wp-xmlrpc-server.php +++ b/src/wp-includes/class-wp-xmlrpc-server.php @@ -374,6 +374,10 @@ public function login_pass_ok( * @param string|array $data Escape single string or array of strings. * @return string|null Returns with string if passed, alters by-reference * when array is passed. + * + * @phpstan-template T of string|array + * @phpstan-param T $data + * @phpstan-param-out T $data */ public function escape( &$data ) { if ( ! is_array( $data ) ) { @@ -387,6 +391,8 @@ public function escape( &$data ) { $v = wp_slash( $v ); } } + + // @phpstan-ignore paramOut.type (The loop only slashes string values in place, so the shape of T is preserved.) return null; } @@ -1658,7 +1664,11 @@ protected function _insert_post( $user, $content_struct ) { return new IXR_Error( 401, __( 'Sorry, you are not allowed to assign a term to one of the given taxonomies.' ) ); } - $term_ids = $post_data['terms'][ $taxonomy ]; + $term_ids = $post_data['terms'][ $taxonomy ]; + if ( ! is_array( $term_ids ) ) { + return new IXR_Error( 403, __( 'Invalid term ID.' ) ); + } + $terms[ $taxonomy ] = array(); foreach ( $term_ids as $term_id ) { $term = get_term_by( 'id', $term_id, $taxonomy ); @@ -1705,11 +1715,23 @@ protected function _insert_post( $user, $content_struct ) { // Filter out non-ambiguous term names. $ambiguous_tax_term_counts = array_filter( $tax_term_names_count, array( $this, '_is_greater_than_one' ) ); - $ambiguous_terms = array_keys( $ambiguous_tax_term_counts ); + // Cast back to strings, since array keys turn numeric names into integers. + $ambiguous_terms = array_map( 'strval', array_keys( $ambiguous_tax_term_counts ) ); } $term_names = $post_data['terms_names'][ $taxonomy ]; + if ( ! is_array( $term_names ) ) { + return new IXR_Error( 401, __( 'Invalid term name.' ) ); + } + foreach ( $term_names as $term_name ) { + // Numeric names may arrive as or values. + if ( is_int( $term_name ) || is_float( $term_name ) ) { + $term_name = (string) $term_name; + } elseif ( ! is_string( $term_name ) ) { + return new IXR_Error( 401, __( 'Invalid term name.' ) ); + } + if ( in_array( $term_name, $ambiguous_terms, true ) ) { return new IXR_Error( 401, __( 'Ambiguous term name used in a hierarchical taxonomy. Please use term ID instead.' ) ); } @@ -2198,6 +2220,10 @@ public function wp_newTerm( $args ) { // Hold the data of the term. $term_data = array(); + if ( isset( $content_struct['name'] ) && ! ( is_string( $content_struct['name'] ) || is_int( $content_struct['name'] ) || is_float( $content_struct['name'] ) ) ) { + return new IXR_Error( 403, __( 'Invalid term name.' ) ); + } + $term_data['name'] = trim( $content_struct['name'] ); if ( empty( $term_data['name'] ) ) { return new IXR_Error( 403, __( 'The term name cannot be empty.' ) ); @@ -2316,6 +2342,10 @@ public function wp_editTerm( $args ) { } if ( isset( $content_struct['name'] ) ) { + if ( ! ( is_string( $content_struct['name'] ) || is_int( $content_struct['name'] ) || is_float( $content_struct['name'] ) ) ) { + return new IXR_Error( 403, __( 'Invalid term name.' ) ); + } + $term_data['name'] = trim( $content_struct['name'] ); if ( empty( $term_data['name'] ) ) { @@ -3541,6 +3571,10 @@ public function wp_newCategory( $args ) { return new IXR_Error( 401, __( 'Sorry, you are not allowed to add a category.' ) ); } + if ( isset( $category['name'] ) && ! ( is_string( $category['name'] ) || is_int( $category['name'] ) || is_float( $category['name'] ) ) ) { + return new IXR_Error( 403, __( 'Invalid term name.' ) ); + } + /* * If no slug was provided, make it empty * so that WordPress will generate one. diff --git a/src/wp-includes/taxonomy.php b/src/wp-includes/taxonomy.php index 6656a2f8e364a..cc1202765fe8a 100644 --- a/src/wp-includes/taxonomy.php +++ b/src/wp-includes/taxonomy.php @@ -2545,10 +2545,11 @@ function wp_get_object_terms( $object_ids, $taxonomies, $args = array() ) { * @global wpdb $wpdb WordPress database abstraction object. * * @since 2.3.0 + * @since 7.2.0 A `$term` that is not a string or number now returns a WP_Error instead of causing a fatal error. * - * @param string $term The term name to add. - * @param string $taxonomy The taxonomy to which to add the term. - * @param array|string $args { + * @param string|int|float $term The term name to add. A number is stored as its string form. + * @param string $taxonomy The taxonomy to which to add the term. + * @param array|string $args { * Optional. Array or query string of arguments for inserting a term. * * @type string $alias_of Slug of the term to make this term an alias of. @@ -2602,7 +2603,14 @@ function wp_insert_term( $term, $taxonomy, $args = array() ) { return new WP_Error( 'invalid_term_id', __( 'Invalid term ID.' ) ); } - if ( '' === trim( $term ) ) { + // Null and false are treated as a missing name rather than an invalid one, as they were before. + if ( null === $term || false === $term ) { + $term = ''; + } elseif ( ! ( is_string( $term ) || is_int( $term ) || is_float( $term ) ) ) { + return new WP_Error( 'invalid_term_name', __( 'Invalid term name.' ) ); + } + + if ( '' === trim( (string) $term ) ) { return new WP_Error( 'empty_term_name', __( 'A name is required for this term.' ) ); } @@ -2957,6 +2965,7 @@ function wp_insert_term( $term, $taxonomy, $args = array() ) { * exists under. * * @since 2.3.0 + * @since 7.2.0 A term that is not a string or number now returns a WP_Error instead of causing a fatal error. * * @global wpdb $wpdb WordPress database abstraction object. * @@ -2983,6 +2992,13 @@ function wp_set_object_terms( $object_id, $terms, $taxonomy, $append = false ) { $terms = array( $terms ); } + // Validate all terms before making any changes. Null and false are skipped below, as they were before. + foreach ( $terms as $term ) { + if ( ! ( is_string( $term ) || is_int( $term ) || is_float( $term ) || null === $term || false === $term ) ) { + return new WP_Error( 'invalid_term_name', __( 'Invalid term name.' ) ); + } + } + if ( ! $append ) { $old_tt_ids = wp_get_object_terms( $object_id, @@ -3001,7 +3017,7 @@ function wp_set_object_terms( $object_id, $terms, $taxonomy, $append = false ) { $new_tt_ids = array(); foreach ( (array) $terms as $term ) { - if ( '' === trim( $term ) ) { + if ( '' === trim( (string) $term ) ) { continue; } diff --git a/tests/phpunit/tests/term/wpInsertTerm.php b/tests/phpunit/tests/term/wpInsertTerm.php index a1f301f6986fb..90b17c26168f5 100644 --- a/tests/phpunit/tests/term/wpInsertTerm.php +++ b/tests/phpunit/tests/term/wpInsertTerm.php @@ -86,6 +86,68 @@ public function test_wp_insert_term_term_trims_to_empty_string() { $this->assertSame( 'empty_term_name', $found->get_error_code() ); } + /** + * Tests that a term name that is not a string or number returns a WP_Error instead of causing a fatal error. + * + * @ticket 66108 + * + * @dataProvider data_wp_insert_term_invalid_term_name_type + * + * @param mixed $name The term name to insert. + * @param non-empty-string $expected_error_code The expected error code. + */ + public function test_wp_insert_term_invalid_term_name_type( $name, string $expected_error_code ): void { + $found = wp_insert_term( $name, 'post_tag' ); // @phpstan-ignore argument.type (Intentionally passing invalid types.) + + $this->assertWPError( $found ); + $this->assertSame( $expected_error_code, $found->get_error_code() ); + } + + /** + * Data provider for {@see self::test_wp_insert_term_invalid_term_name_type()}. + * + * @return array + */ + public function data_wp_insert_term_invalid_term_name_type(): array { + return array( + 'array' => array( array( 'foo', 'bar' ), 'invalid_term_name' ), + 'object' => array( new stdClass(), 'invalid_term_name' ), + 'true' => array( true, 'invalid_term_name' ), + 'false' => array( false, 'empty_term_name' ), + 'null' => array( null, 'empty_term_name' ), + ); + } + + /** + * Tests that a numeric term name is inserted and stored as its string form. + * + * @ticket 66108 + * + * @dataProvider data_wp_insert_term_numeric_term_name + * + * @param int|float $name The term name to insert. + */ + public function test_wp_insert_term_numeric_term_name( $name ): void { + $found = wp_insert_term( $name, 'post_tag' ); + + $this->assertNotWPError( $found ); + $term = get_term( $found['term_id'], 'post_tag' ); + $this->assertInstanceOf( WP_Term::class, $term ); + $this->assertSame( (string) $name, $term->name ); + } + + /** + * Data provider for {@see self::test_wp_insert_term_numeric_term_name()}. + * + * @return array + */ + public function data_wp_insert_term_numeric_term_name(): array { + return array( + 'integer' => array( 2024 ), + 'float' => array( 1.5 ), + ); + } + public function test_wp_insert_term_parent_does_not_exist() { $found = wp_insert_term( 'foo', diff --git a/tests/phpunit/tests/term/wpSetObjectTerms.php b/tests/phpunit/tests/term/wpSetObjectTerms.php index 3734b00828ba1..487578597da09 100644 --- a/tests/phpunit/tests/term/wpSetObjectTerms.php +++ b/tests/phpunit/tests/term/wpSetObjectTerms.php @@ -7,6 +7,10 @@ */ class Tests_Term_WpSetObjectTerms extends WP_UnitTestCase { protected static $taxonomy = 'category'; + + /** + * @var int[] + */ protected static $post_ids = array(); protected static $term_ids = array(); @@ -15,6 +19,54 @@ public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { self::$term_ids = $factory->term->create_many( 5, array( 'taxonomy' => self::$taxonomy ) ); } + /** + * Tests that a term that is not a string or number returns a WP_Error without changing existing terms. + * + * @ticket 66108 + * + * @dataProvider data_invalid_term_type + * + * @param mixed $term The invalid term to set alongside a valid one. + */ + public function test_invalid_term_type( $term ): void { + $post_id = self::$post_ids[0]; + wp_set_object_terms( $post_id, array( 'existing' ), 'post_tag' ); + + $result = wp_set_object_terms( $post_id, array( 'new', $term ), 'post_tag' ); + + $this->assertWPError( $result ); + $this->assertSame( 'invalid_term_name', $result->get_error_code() ); + $this->assertSame( array( 'existing' ), wp_get_object_terms( $post_id, 'post_tag', array( 'fields' => 'names' ) ) ); + $this->assertNull( term_exists( 'new', 'post_tag' ) ); + } + + /** + * Data provider for {@see self::test_invalid_term_type()}. + * + * @return array + */ + public function data_invalid_term_type(): array { + return array( + 'array' => array( array( 'foo' ) ), + 'object' => array( new stdClass() ), + 'true' => array( true ), + ); + } + + /** + * Tests that null and false terms are skipped rather than rejected. + * + * @ticket 66108 + */ + public function test_null_and_false_terms_are_skipped(): void { + $post_id = self::$post_ids[0]; + + $result = wp_set_object_terms( $post_id, array( 'foo', null, false ), 'post_tag' ); + + $this->assertNotWPError( $result ); + $this->assertSame( array( 'foo' ), wp_get_object_terms( $post_id, 'post_tag', array( 'fields' => 'names' ) ) ); + } + /** * @ticket 26570 */ diff --git a/tests/phpunit/tests/xmlrpc/mw/editPost.php b/tests/phpunit/tests/xmlrpc/mw/editPost.php index 23515233325ff..3cd4101212c6b 100644 --- a/tests/phpunit/tests/xmlrpc/mw/editPost.php +++ b/tests/phpunit/tests/xmlrpc/mw/editPost.php @@ -30,6 +30,26 @@ public function test_edit_own_post() { $this->assertSame( $new_title, $out->post_title ); } + /** + * Tests that a keyword that is not a string or number does not cause a fatal error. + * + * @ticket 66108 + */ + public function test_invalid_keyword(): void { + $author_id = $this->make_user_by_role( 'author' ); + $post_id = self::factory()->post->create( array( 'post_author' => $author_id ) ); + wp_set_post_tags( $post_id, array( 'existing' ) ); + + $post = array( + 'title' => 'Test', + 'mt_keywords' => array( 'foo', array( 'bar' ) ), + ); + $result = $this->myxmlrpcserver->mw_editPost( array( $post_id, 'author', 'author', $post ) ); + + $this->assertNotIXRError( $result ); + $this->assertSame( array( 'existing' ), wp_get_post_tags( $post_id, array( 'fields' => 'names' ) ) ); + } + public function test_capable_edit_others_post() { $this->make_user_by_role( 'editor' ); $contributor_id = $this->make_user_by_role( 'contributor' ); diff --git a/tests/phpunit/tests/xmlrpc/mw/newPost.php b/tests/phpunit/tests/xmlrpc/mw/newPost.php index e7c4af0cee01f..50dbd476b5f36 100644 --- a/tests/phpunit/tests/xmlrpc/mw/newPost.php +++ b/tests/phpunit/tests/xmlrpc/mw/newPost.php @@ -21,6 +21,24 @@ public function test_incapable_user() { $this->assertSame( 401, $result->code ); } + /** + * Tests that a keyword that is not a string or number does not cause a fatal error. + * + * @ticket 66108 + */ + public function test_invalid_keyword(): void { + $this->make_user_by_role( 'author' ); + + $post = array( + 'title' => 'Test', + 'mt_keywords' => array( 'foo', array( 'bar' ) ), + ); + $result = $this->myxmlrpcserver->mw_newPost( array( 1, 'author', 'author', $post ) ); + + $this->assertNotIXRError( $result ); + $this->assertSame( array(), wp_get_post_tags( (int) $result, array( 'fields' => 'names' ) ) ); + } + public function test_no_content() { $this->make_user_by_role( 'author' ); diff --git a/tests/phpunit/tests/xmlrpc/wp/editTerm.php b/tests/phpunit/tests/xmlrpc/wp/editTerm.php index 9b367681f3e95..a6a8e3e166027 100644 --- a/tests/phpunit/tests/xmlrpc/wp/editTerm.php +++ b/tests/phpunit/tests/xmlrpc/wp/editTerm.php @@ -4,6 +4,9 @@ * @group xmlrpc */ class Tests_XMLRPC_wp_editTerm extends WP_XMLRPC_UnitTestCase { + /** + * @var int + */ protected static $parent_term; protected static $child_term; protected static $post_tag; @@ -114,6 +117,47 @@ public function test_empty_term() { $this->assertSame( __( 'Empty Term.' ), $result->message ); } + /** + * Tests that a term name that is not a string or number returns an error instead of causing a fatal error. + * + * @ticket 66108 + * + * @dataProvider data_invalid_term_name + * + * @param mixed $name The term name to send. + */ + public function test_invalid_term_name( $name ): void { + $this->make_user_by_role( 'editor' ); + + $result = $this->myxmlrpcserver->wp_editTerm( + array( + 1, + 'editor', + 'editor', + self::$parent_term, + array( + 'taxonomy' => 'category', + 'name' => $name, + ), + ) + ); + $this->assertIXRError( $result ); + $this->assertSame( 403, $result->code ); + $this->assertSame( 'Invalid term name.', $result->message ); + } + + /** + * Data provider for {@see self::test_invalid_term_name()}. + * + * @return array + */ + public function data_invalid_term_name(): array { + return array( + 'array' => array( array( 'foo' ) ), + 'boolean' => array( true ), + ); + } + public function test_empty_term_name() { $this->make_user_by_role( 'editor' ); diff --git a/tests/phpunit/tests/xmlrpc/wp/newCategory.php b/tests/phpunit/tests/xmlrpc/wp/newCategory.php new file mode 100644 index 0000000000000..bc76e5571df91 --- /dev/null +++ b/tests/phpunit/tests/xmlrpc/wp/newCategory.php @@ -0,0 +1,57 @@ +make_user_by_role( 'editor' ); + + $result = $this->myxmlrpcserver->wp_newCategory( array( 1, 'editor', 'editor', array( 'name' => 'Foo' ) ) ); + + $this->assertNotIXRError( $result ); + $this->assertIsInt( $result ); + $category = get_term( $result, 'category' ); + $this->assertInstanceOf( WP_Term::class, $category ); + $this->assertSame( 'Foo', $category->name ); + } + + /** + * Tests that a category name that is not a string or number returns an error instead of causing a fatal error. + * + * @ticket 66108 + * + * @dataProvider data_invalid_category_name + * + * @param mixed $name The category name to send. + */ + public function test_invalid_category_name( $name ): void { + $this->make_user_by_role( 'editor' ); + + $result = $this->myxmlrpcserver->wp_newCategory( array( 1, 'editor', 'editor', array( 'name' => $name ) ) ); + + $this->assertIXRError( $result ); + $this->assertSame( 403, $result->code ); + $this->assertSame( 'Invalid term name.', $result->message ); + } + + /** + * Data provider for {@see self::test_invalid_category_name()}. + * + * @return array + */ + public function data_invalid_category_name(): array { + return array( + 'array' => array( array( 'foo' ) ), + 'boolean' => array( true ), + ); + } +} diff --git a/tests/phpunit/tests/xmlrpc/wp/newPost.php b/tests/phpunit/tests/xmlrpc/wp/newPost.php index c6386a1f23d5b..bf4463df84bcd 100644 --- a/tests/phpunit/tests/xmlrpc/wp/newPost.php +++ b/tests/phpunit/tests/xmlrpc/wp/newPost.php @@ -345,6 +345,159 @@ public function test_terms_names() { $this->assertSame( 401, $result2->code ); } + /** + * Tests that an invalid term name in `terms_names` returns an error instead of causing a fatal error. + * + * @ticket 66108 + * + * @dataProvider data_terms_names_invalid_term_name + * + * @param mixed $term_name The term name to send. + */ + public function test_terms_names_invalid_term_name( $term_name ): void { + $this->make_user_by_role( 'editor' ); + + $post = array( + 'post_title' => 'Test', + 'terms_names' => array( + 'post_tag' => array( $term_name ), + ), + ); + $result = $this->myxmlrpcserver->wp_newPost( array( 1, 'editor', 'editor', $post ) ); + + $this->assertIXRError( $result ); + $this->assertSame( 401, $result->code ); + $this->assertSame( 'Invalid term name.', $result->message ); + } + + /** + * Data provider for {@see self::test_terms_names_invalid_term_name()}. + * + * @return array + */ + public function data_terms_names_invalid_term_name(): array { + return array( + 'array' => array( array( 'foo', 'bar' ) ), + 'boolean' => array( true ), + ); + } + + /** + * Tests that a `terms` or `terms_names` value that is not an array returns an error. + * + * @ticket 66108 + * + * @dataProvider data_terms_not_array + * + * @param 'terms'|'terms_names' $field The post field to send. + * @param mixed $value The value to send for the taxonomy. + * @param int $expected_code The expected fault code. + * @param non-empty-string $expected_message The expected fault message. + */ + public function test_terms_not_array( string $field, $value, int $expected_code, string $expected_message ): void { + $this->make_user_by_role( 'editor' ); + + $post = array( + 'post_title' => 'Test', + $field => array( + 'post_tag' => $value, + ), + ); + $result = $this->myxmlrpcserver->wp_newPost( array( 1, 'editor', 'editor', $post ) ); + + $this->assertIXRError( $result ); + $this->assertSame( $expected_code, $result->code ); + $this->assertSame( $expected_message, $result->message ); + } + + /** + * Data provider for {@see self::test_terms_not_array()}. + * + * @return array + */ + public function data_terms_not_array(): array { + return array( + 'terms string' => array( 'terms', '1', 403, 'Invalid term ID.' ), + 'terms integer' => array( 'terms', 1, 403, 'Invalid term ID.' ), + 'terms_names string' => array( 'terms_names', 'foo', 401, 'Invalid term name.' ), + ); + } + + /** + * Tests that a numeric term name in `terms_names` is assigned using its string form. + * + * @ticket 66108 + * + * @dataProvider data_terms_names_numeric_term_name + * + * @param int|float $term_name The term name to send. + */ + public function test_terms_names_numeric_term_name( $term_name ): void { + $this->make_user_by_role( 'editor' ); + + $post = array( + 'post_title' => 'Test', + 'terms_names' => array( + 'post_tag' => array( $term_name ), + ), + ); + $result = $this->myxmlrpcserver->wp_newPost( array( 1, 'editor', 'editor', $post ) ); + + $this->assertNotIXRError( $result ); + $this->assertSame( array( (string) $term_name ), wp_get_post_tags( (int) $result, array( 'fields' => 'names' ) ) ); + } + + /** + * Data provider for {@see self::test_terms_names_numeric_term_name()}. + * + * @return array + */ + public function data_terms_names_numeric_term_name(): array { + return array( + 'integer' => array( 2024 ), + 'float' => array( 1.5 ), + ); + } + + /** + * Tests that an ambiguous numeric term name in a hierarchical taxonomy returns an error. + * + * @ticket 66108 + * + * @dataProvider data_terms_names_ambiguous_numeric_term_name + * + * @param string|int $term_name The term name to send. + */ + public function test_terms_names_ambiguous_numeric_term_name( $term_name ): void { + $this->make_user_by_role( 'editor' ); + + $parent_cat = wp_create_category( '2024' ); + wp_create_category( '2024', $parent_cat ); + + $post = array( + 'post_title' => 'Test', + 'terms_names' => array( + 'category' => array( $term_name ), + ), + ); + $result = $this->myxmlrpcserver->wp_newPost( array( 1, 'editor', 'editor', $post ) ); + + $this->assertIXRError( $result ); + $this->assertSame( 401, $result->code ); + } + + /** + * Data provider for {@see self::test_terms_names_ambiguous_numeric_term_name()}. + * + * @return array + */ + public function data_terms_names_ambiguous_numeric_term_name(): array { + return array( + 'string' => array( '2024' ), + 'integer' => array( 2024 ), + ); + } + /** * @ticket 28601 */ diff --git a/tests/phpunit/tests/xmlrpc/wp/newTerm.php b/tests/phpunit/tests/xmlrpc/wp/newTerm.php index fdd9a4803e7b4..a9a44a0384603 100644 --- a/tests/phpunit/tests/xmlrpc/wp/newTerm.php +++ b/tests/phpunit/tests/xmlrpc/wp/newTerm.php @@ -48,6 +48,46 @@ public function test_incapable_user() { $this->assertSame( __( 'Sorry, you are not allowed to create terms in this taxonomy.' ), $result->message ); } + /** + * Tests that a term name that is not a string or number returns an error instead of causing a fatal error. + * + * @ticket 66108 + * + * @dataProvider data_invalid_term_name + * + * @param mixed $name The term name to send. + */ + public function test_invalid_term_name( $name ): void { + $this->make_user_by_role( 'editor' ); + + $result = $this->myxmlrpcserver->wp_newTerm( + array( + 1, + 'editor', + 'editor', + array( + 'taxonomy' => 'category', + 'name' => $name, + ), + ) + ); + $this->assertIXRError( $result ); + $this->assertSame( 403, $result->code ); + $this->assertSame( 'Invalid term name.', $result->message ); + } + + /** + * Data provider for {@see self::test_invalid_term_name()}. + * + * @return array + */ + public function data_invalid_term_name(): array { + return array( + 'array' => array( array( 'foo' ) ), + 'boolean' => array( true ), + ); + } + public function test_empty_term() { $this->make_user_by_role( 'editor' );