From f62a63e2008b32715e7a233615bce1fc1bd8c88f Mon Sep 17 00:00:00 2001 From: Sukhendu Sekhar Guria Date: Thu, 21 May 2026 17:13:30 +0530 Subject: [PATCH] Improve WP_Term sanitization bypass --- src/wp-includes/class-wp-term.php | 23 ++++-- src/wp-includes/taxonomy.php | 12 +++- tests/phpunit/tests/term/cache.php | 26 +++++++ tests/phpunit/tests/term/getTerm.php | 41 +++++++++++ tests/phpunit/tests/term/sanitizeTerm.php | 39 ++++++++++ tests/phpunit/tests/term/wpTerm.php | 88 ++++++++++++++++++++++- 6 files changed, 223 insertions(+), 6 deletions(-) diff --git a/src/wp-includes/class-wp-term.php b/src/wp-includes/class-wp-term.php index 0cefa3097b393..988e774b91ca1 100644 --- a/src/wp-includes/class-wp-term.php +++ b/src/wp-includes/class-wp-term.php @@ -181,10 +181,12 @@ public static function get_instance( $term_id, $taxonomy = null ) { } } - $term_obj = new WP_Term( $_term ); - $term_obj->filter( $term_obj->filter ); + if ( empty( $_term->filter ) ) { + $_term = sanitize_term( $_term, $_term->taxonomy, 'raw' ); + wp_cache_replace( $term_id, $_term, 'terms' ); + } - return $term_obj; + return new WP_Term( $_term ); } /** @@ -206,9 +208,22 @@ public function __construct( $term ) { * @since 4.4.0 * * @param string $filter Filter context. Accepts 'edit', 'db', 'display', 'attribute', 'js', 'rss', or 'raw'. + * @return WP_Term Sanitized term object. */ public function filter( $filter ) { - sanitize_term( $this, $this->taxonomy, $filter ); + if ( $this->filter === $filter ) { + return $this; + } + + if ( 'raw' === $filter ) { + $raw = self::get_instance( $this->term_id, $this->taxonomy ); + if ( $raw instanceof WP_Term ) { + return $raw; + } + return $this; + } + + return sanitize_term( $this, $this->taxonomy, $filter ); } /** diff --git a/src/wp-includes/taxonomy.php b/src/wp-includes/taxonomy.php index 80f457de0e6f7..2ae032a572817 100644 --- a/src/wp-includes/taxonomy.php +++ b/src/wp-includes/taxonomy.php @@ -1046,7 +1046,7 @@ function get_term( $term, $taxonomy = '', $output = OBJECT, $filter = 'raw' ) { // Sanitize term, according to the specified filter. if ( $_term !== $old_term || $_term->filter !== $filter ) { - $_term->filter( $filter ); + $_term = $_term->filter( $filter ); } if ( ARRAY_A === $output ) { @@ -1723,6 +1723,16 @@ function sanitize_term( $term, $taxonomy, $context = 'display' ) { $do_object = is_object( $term ); + if ( 'raw' !== $context ) { + if ( $do_object ) { + if ( isset( $term->filter ) && $context === $term->filter ) { + return $term; + } + } elseif ( isset( $term['filter'] ) && $context === $term['filter'] ) { + return $term; + } + } + $term_id = $do_object ? ( $term->term_id ?? 0 ) : ( $term['term_id'] ?? 0 ); foreach ( (array) $fields as $field ) { diff --git a/tests/phpunit/tests/term/cache.php b/tests/phpunit/tests/term/cache.php index 76eff31adf176..40d891c63aee0 100644 --- a/tests/phpunit/tests/term/cache.php +++ b/tests/phpunit/tests/term/cache.php @@ -229,6 +229,32 @@ public function test_term_objects_should_not_be_modified_by_update_term_cache() } } + /** + * @ticket 50568 + */ + public function test_get_instance_should_prime_unfiltered_cached_term_as_raw() { + register_taxonomy( 'wptests_tax', 'post' ); + $term_id = self::factory()->term->create( array( 'taxonomy' => 'wptests_tax' ) ); + $term = get_term( $term_id, 'wptests_tax' ); + + $this->assertInstanceOf( 'WP_Term', $term ); + + $term->filter = ''; + wp_cache_delete( $term_id, 'terms' ); + update_term_cache( array( $term ) ); + + $cached_term = wp_cache_get( $term_id, 'terms' ); + $this->assertSame( '', $cached_term->filter ); + + $found = WP_Term::get_instance( $term_id ); + + $this->assertInstanceOf( 'WP_Term', $found ); + $this->assertSame( 'raw', $found->filter ); + + $cached_term = wp_cache_get( $term_id, 'terms' ); + $this->assertSame( 'raw', $cached_term->filter ); + } + /** * @ticket 21760 */ diff --git a/tests/phpunit/tests/term/getTerm.php b/tests/phpunit/tests/term/getTerm.php index a72ebca40ad40..3c222296ceb6d 100644 --- a/tests/phpunit/tests/term/getTerm.php +++ b/tests/phpunit/tests/term/getTerm.php @@ -178,6 +178,47 @@ public function test_numeric_properties_should_be_cast_to_ints() { } } + /** + * @ticket 50568 + */ + public function test_raw_filter_object_should_normalize_integer_fields() { + $term = new stdClass(); + $term->term_id = (string) self::$term->term_id; + $term->term_taxonomy_id = '123'; + $term->parent = '0'; + $term->count = '7'; + $term->term_group = '0'; + $term->filter = 'raw'; + $term->name = 'Test Term'; + $term->slug = 'test-term'; + $term->taxonomy = 'wptests_tax'; + + $found = get_term( $term, 'wptests_tax' ); + + $this->assertInstanceOf( 'WP_Term', $found ); + $this->assertSame( self::$term->term_id, $found->term_id ); + $this->assertSame( 123, $found->term_taxonomy_id ); + $this->assertSame( 0, $found->parent ); + $this->assertSame( 7, $found->count ); + $this->assertSame( 0, $found->term_group ); + $this->assertSame( 'raw', $found->filter ); + } + + /** + * @ticket 50568 + */ + public function test_display_filtered_term_should_be_returned_raw_when_raw_filter_is_requested() { + $display_term = get_term( self::$term->term_id, 'wptests_tax', OBJECT, 'display' ); + + $this->assertInstanceOf( 'WP_Term', $display_term ); + $this->assertSame( 'display', $display_term->filter ); + + $raw_term = get_term( $display_term, 'wptests_tax', OBJECT, 'raw' ); + + $this->assertInstanceOf( 'WP_Term', $raw_term ); + $this->assertSame( 'raw', $raw_term->filter ); + } + /** * @ticket 34332 */ diff --git a/tests/phpunit/tests/term/sanitizeTerm.php b/tests/phpunit/tests/term/sanitizeTerm.php index 44e57d71c27ed..5cafe61fa0b1b 100644 --- a/tests/phpunit/tests/term/sanitizeTerm.php +++ b/tests/phpunit/tests/term/sanitizeTerm.php @@ -119,4 +119,43 @@ public function data_sanitize_term(): array { ), ); } + + /** + * @ticket 50568 + */ + public function test_sanitize_term_should_return_early_when_object_already_filtered_for_context() { + $term = new stdClass(); + $term->term_id = 1; + $term->name = 'Test'; + $term->slug = 'test'; + $term->taxonomy = 'category'; + $term->description = ''; + $term->filter = 'edit'; + + $count = did_filter( 'edit_term_name' ); + + $result = sanitize_term( $term, 'category', 'edit' ); + + $this->assertSame( $term, $result ); + $this->assertSame( $count, did_filter( 'edit_term_name' ), 'edit_term_name filter should not have fired.' ); + } + + /** + * @ticket 50568 + */ + public function test_sanitize_term_should_return_early_when_array_already_filtered_for_context() { + $term = array( + 'term_id' => 1, + 'name' => 'Test', + 'slug' => 'test', + 'filter' => 'display', + ); + + $count = did_filter( 'term_name' ); + + $result = sanitize_term( $term, 'category', 'display' ); + + $this->assertSame( $term, $result ); + $this->assertSame( $count, did_filter( 'term_name' ), 'term_name filter should not have fired.' ); + } } diff --git a/tests/phpunit/tests/term/wpTerm.php b/tests/phpunit/tests/term/wpTerm.php index e640cf8120732..7ab0c03fe6306 100644 --- a/tests/phpunit/tests/term/wpTerm.php +++ b/tests/phpunit/tests/term/wpTerm.php @@ -5,16 +5,19 @@ */ class Tests_Term_WpTerm extends WP_UnitTestCase { protected static $term_id; + protected static $shared_terms = array(); public function set_up() { parent::set_up(); register_taxonomy( 'wptests_tax', 'post' ); + register_taxonomy( 'wptests_tax_2', 'post' ); } public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { global $wpdb; register_taxonomy( 'wptests_tax', 'post' ); + register_taxonomy( 'wptests_tax_2', 'post' ); // Ensure that there is a term with ID 1. if ( ! get_term( 1 ) ) { @@ -36,7 +39,44 @@ public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { clean_term_cache( 1, 'wptests_tax' ); } - self::$term_id = $factory->term->create( array( 'taxonomy' => 'wptests_tax' ) ); + self::$term_id = $factory->term->create( array( 'taxonomy' => 'wptests_tax' ) ); + self::$shared_terms = self::generate_shared_terms(); + } + + /** + * Utility function for generating two shared terms, in the 'wptests_tax' and 'wptests_tax_2' taxonomies. + * + * @return array Array of term_id/old_term_id/term_taxonomy_id triplets. + */ + protected static function generate_shared_terms() { + global $wpdb; + + $term_1 = wp_insert_term( 'Foo', 'wptests_tax' ); + $term_2 = wp_insert_term( 'Foo', 'wptests_tax_2' ); + + // Manually modify because shared terms shouldn't naturally occur. + $wpdb->update( + $wpdb->term_taxonomy, + array( 'term_id' => $term_1['term_id'] ), + array( 'term_taxonomy_id' => $term_2['term_taxonomy_id'] ), + array( '%d' ), + array( '%d' ) + ); + + clean_term_cache( $term_1['term_id'] ); + + return array( + array( + 'term_id' => $term_1['term_id'], + 'old_term_id' => $term_1['term_id'], + 'term_taxonomy_id' => $term_1['term_taxonomy_id'], + ), + array( + 'term_id' => $term_1['term_id'], + 'old_term_id' => $term_2['term_id'], + 'term_taxonomy_id' => $term_2['term_taxonomy_id'], + ), + ); } /** @@ -89,4 +129,50 @@ public function test_get_instance_should_respect_taxonomy_when_term_id_is_found_ $found = WP_Term::get_instance( self::$term_id, 'wptests_tax2' ); $this->assertFalse( $found ); } + + /** + * @ticket 50568 + */ + public function test_filter_should_return_same_instance_when_context_matches() { + $term = WP_Term::get_instance( self::$term_id ); + + $this->assertSame( 'raw', $term->filter ); + $this->assertSame( $term, $term->filter( 'raw' ) ); + } + + /** + * @ticket 50568 + */ + public function test_filter_raw_should_return_raw_instance_from_display_filtered_term() { + $term = WP_Term::get_instance( self::$term_id ); + $display_term = $term->filter( 'display' ); + + $this->assertSame( 'display', $display_term->filter ); + + $raw_term = $display_term->filter( 'raw' ); + + $this->assertInstanceOf( 'WP_Term', $raw_term ); + $this->assertSame( 'raw', $raw_term->filter ); + $this->assertNotSame( $display_term, $raw_term ); + } + + /** + * @ticket 50568 + */ + public function test_filter_raw_should_use_taxonomy_to_disambiguate_shared_terms() { + $terms = self::$shared_terms; + + $display_term = get_term( $terms[0]['term_id'], 'wptests_tax', OBJECT, 'display' ); + + $this->assertInstanceOf( 'WP_Term', $display_term ); + $this->assertSame( 'display', $display_term->filter ); + $this->assertSame( 'wptests_tax', $display_term->taxonomy ); + + $raw_term = $display_term->filter( 'raw' ); + + $this->assertInstanceOf( 'WP_Term', $raw_term ); + $this->assertSame( 'raw', $raw_term->filter ); + $this->assertSame( 'wptests_tax', $raw_term->taxonomy ); + $this->assertSame( $terms[0]['term_taxonomy_id'], $raw_term->term_taxonomy_id ); + } }