diff --git a/src/wp-includes/formatting.php b/src/wp-includes/formatting.php index 234d71a2a175a..0573d3d0a32be 100644 --- a/src/wp-includes/formatting.php +++ b/src/wp-includes/formatting.php @@ -5852,6 +5852,121 @@ function wp_spaces_regexp() { return $spaces; } +/** + * Recursively sanitizes data by detecting the data type and applying appropriate sanitization. + * + * This function iterates through each value in arrays and objects, detects the data type, + * and executes the sanitization function that best fits the data. It provides a comprehensive + * solution for plugin developers to sanitize complex data structures without having to + * manually handle each data type. + * + * The function supports custom sanitization contexts through the $context parameter: + * - 'text': Uses sanitize_text_field() for strings + * - 'textarea': Uses sanitize_textarea_field() for strings (preserves newlines) + * - 'email': Uses sanitize_email() for strings + * - 'url': Uses sanitize_url() for strings + * - 'key': Uses sanitize_key() for strings + * - 'title': Uses sanitize_title() for strings + * - 'html_class': Uses sanitize_html_class() for strings + * - 'mime_type': Uses sanitize_mime_type() for strings + * - 'auto' (default): Auto-detects the best sanitization method + * + * @since 6.5.0 + * + * @param mixed $data The data to sanitize. Can be string, array, object, or other types. + * @param string $context Optional. The sanitization context. Default 'auto'. + * @param int $depth Optional. Internal recursion depth counter. Default 0. + * @return mixed Sanitized data in the same structure as the input. + */ +function recursively_sanitize( $data, $context = 'auto', $depth = 0 ) { + // Prevent infinite recursion - limit to reasonable depth. + if ( $depth > 50 ) { + return null; + } + + // Handle arrays recursively. + if ( is_array( $data ) ) { + $sanitized = array(); + foreach ( $data as $key => $value ) { + $sanitized_key = sanitize_key( $key ); + $sanitized[ $sanitized_key ] = recursively_sanitize( $value, $context, $depth + 1 ); + } + return $sanitized; + } + + // Handle objects recursively. + if ( is_object( $data ) ) { + $sanitized = new stdClass(); + foreach ( $data as $key => $value ) { + $sanitized_key = sanitize_key( $key ); + $sanitized->$sanitized_key = recursively_sanitize( $value, $context, $depth + 1 ); + } + return $sanitized; + } + + // Handle null, boolean, and numeric values. + if ( is_null( $data ) || is_bool( $data ) || is_numeric( $data ) ) { + return $data; + } + + // Handle string sanitization based on context. + if ( is_string( $data ) ) { + switch ( $context ) { + case 'text': + $sanitized = sanitize_text_field( $data ); + break; + case 'textarea': + $sanitized = sanitize_textarea_field( $data ); + break; + case 'email': + $sanitized = sanitize_email( $data ); + break; + case 'url': + $sanitized = sanitize_url( $data ); + break; + case 'key': + $sanitized = sanitize_key( $data ); + break; + case 'title': + $sanitized = sanitize_title( $data ); + break; + case 'html_class': + $sanitized = sanitize_html_class( $data ); + break; + case 'mime_type': + $sanitized = sanitize_mime_type( $data ); + break; + case 'auto': + default: + // Auto-detect best sanitization method. + if ( is_email( $data ) ) { + $sanitized = sanitize_email( $data ); + } elseif ( wp_http_validate_url( $data ) ) { + $sanitized = sanitize_url( $data ); + } else { + // Default to text field sanitization. + $sanitized = sanitize_text_field( $data ); + } + break; + } + } else { + $sanitized = sanitize_text_field( (string) $data ); + } + + /** + * Filters the recursively sanitized data. + * + * @since 6.5.0 + * + * @param mixed $sanitized The sanitized data. + * @param mixed $data The original data before sanitization. + * @param string $context The sanitization context used. + * @param int $depth The current recursion depth. + */ + return apply_filters( 'recursively_sanitize', $sanitized, $data, $context, $depth ); +} + + /** * Enqueues the important emoji-related styles. * diff --git a/tests/phpunit/tests/formatting/recursivelySanitize.php b/tests/phpunit/tests/formatting/recursivelySanitize.php new file mode 100644 index 0000000000000..d0936815b3a3e --- /dev/null +++ b/tests/phpunit/tests/formatting/recursivelySanitize.php @@ -0,0 +1,129 @@ +assertEquals( $expected, recursively_sanitize( $input, $context ) ); + } + + public function data_recursively_sanitize() { + return array( + // Empty array. + array( array(), 'auto', array() ), + + // Simple string. + array( 'Text with ', 'auto', 'Text with' ), + + // Array with sanitized keys and values. + array( + array( + 'text' => 'Simple text', + 'key with spaces' => 'value', + ), + 'auto', + array( + 'text' => 'Simple text', + 'keywithspaces' => 'value', + ), + ), + + // Nested array. + array( + array( + 'level1' => array( + 'text' => 'Text with ', + ), + ), + 'auto', + array( + 'level1' => array( + 'text' => 'Text with', + ), + ), + ), + + // Object. + array( + (object) array( + 'text' => 'Text with ', + 'key with spaces' => 'value', + ), + 'auto', + (object) array( + 'text' => 'Text with', + 'keywithspaces' => 'value', + ), + ), + + // Mixed array and object. + array( + array( + 'user' => (object) array( + 'profile' => array( + 'bio' => 'Bio with ', + ), + ), + ), + 'auto', + array( + 'user' => (object) array( + 'profile' => array( + 'bio' => 'Bio with', + ), + ), + ), + ), + + // Primitive types. + array( null, 'auto', null ), + array( true, 'auto', true ), + array( false, 'auto', false ), + array( 42, 'auto', 42 ), + + // Context-specific sanitization. + array( + array( 'text' => "Multi-line\ntext" ), + 'text', + array( 'text' => 'Multi-line text' ), + ), + array( + array( 'text' => "Multi-line\ntext" ), + 'textarea', + array( 'text' => "Multi-line\ntext" ), + ), + ); + } + + public function test_recursively_sanitize_should_prevent_infinite_recursion() { + // Create a structure with many levels of nesting. + $input = 'test'; + for ( $i = 0; $i < 60; $i++ ) { + $input = array( 'level' => $input ); + } + + // This should not cause infinite recursion or fatal errors + $result = recursively_sanitize( $input, 'auto' ); + + // The function should handle deep nesting gracefully + $this->assertTrue( is_array( $result ) || is_null( $result ) ); + } + + public function test_recursively_sanitize_filter() { + $filter = new MockAction(); + add_filter( 'recursively_sanitize', array( $filter, 'filter' ) ); + + recursively_sanitize( 'test string', 'auto' ); + + $this->assertSame( 1, $filter->get_call_count() ); + + remove_filter( 'recursively_sanitize', array( $filter, 'filter' ) ); + } +}