diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 9394b75989912..5a4b4f8134ade 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -947,18 +947,21 @@ * * @see wp_kses_post() for specifically filtering post content and fields. * @see wp_allowed_protocols() for the default allowed protocols in link URLs. + * @see wp_sanitize_html() for a modern implementation based on the HTML API. * * @since 1.0.0 * * @param string $content Text content to filter. * @param array[]|string $allowed_html An array of allowed HTML elements and attributes, - * or a context name such as 'post'. See wp_kses_allowed_html() + * or a context name such as 'post'. {@see wp_kses_allowed_html()} * for the list of accepted context names. * @param string[] $allowed_protocols Optional. Array of allowed URL protocols. * Defaults to the result of wp_allowed_protocols(). * @return string Filtered content containing only the allowed HTML. */ function wp_kses( $content, $allowed_html, $allowed_protocols = array() ) { + return wp_sanitize_html_kses( (string) $content, $allowed_html, $allowed_protocols ); + if ( empty( $allowed_protocols ) ) { $allowed_protocols = wp_allowed_protocols(); } @@ -970,6 +973,628 @@ function wp_kses( $content, $allowed_html, $allowed_protocols = array() ) { return wp_kses_split( $content, $allowed_html, $allowed_protocols ); } +/** + * Filters HTML content, sanitizing according to given policies. + * + * Modern implementation of {@see wp_kses()} which parses via the HTML API. + * + * @since {WP_VERSION} + * + * @param string $content Text content to filter. + * @param array[]|string $allowed_html An array of allowed HTML elements and attributes, + * or a context name such as 'post'. See wp_kses_allowed_html() + * for the list of accepted context names. + * @param string[] $allowed_protocols Optional. Array of allowed URL protocols. + * Defaults to the result of wp_allowed_protocols(). + * @return string Filtered content containing only the allowed HTML. + */ +function wp_sanitize_html_kses( $content, $allowed_html, $allowed_protocols = array() ) { + $allowed_html = is_array( $allowed_html ) + ? $allowed_html + : wp_kses_allowed_html( $allowed_html ); + + $allowed_protocols = empty( $allowed_protocols ) + ? wp_allowed_protocols() + : $allowed_protocols; + + /* + * The explanation for this call is that “the quoting from `preg_replace(//e)` + * requires” it, but this version of `wp_kses()` doesn’t rely on PCRE functions + * to parse HTML. Given that this corrupts text, it will be skipped. + */ + //$content = wp_kses_stripslashes( $content ); + + $processor = new class( $content, $allowed_html, $allowed_protocols ) extends WP_HTML_Tag_Processor { + private $allowed_html; + + private $allowed_protocols; + + private $uris; + + public function __construct( $html, $allowed_html, $allowed_protocols ) { + parent::__construct( $html ); + + $this->allowed_html = $allowed_html; + $this->allowed_protocols = $allowed_protocols; + $this->uris = wp_kses_uri_attributes(); + } + + private function get_span() { + $this->set_bookmark( 'here' ); + + if ( ! isset( $this->bookmarks['here'] ) ) { + return null; + } + + return $this->bookmarks['here']; + } + + public function set_attribute( $name, $value ): bool { + $given_value = $value; + $is_url_ish = in_array( $name, $this->uris, true ); + + if ( is_string( $value ) && '' !== $value && $is_url_ish ) { + $value = wp_kses_bad_protocol( $value, $this->allowed_protocols ); + } + + if ( ! parent::set_attribute( $name, $value ) ) { + return false; + } + + /** + * Legacy `wp_kses()` does not add the http/https prefix that `esc_url()` adds + * when a given URL contains a relative path containing no path separators, + * e.g. "foo" or "smile.png". This "undo" preserves that behavior. + * + * This stems from an ambiguity inside {@see \esc_url()} whereby it treats URLs + * with no path separators, no `?`, and no `#` as domains, thus prefixing the + * HTTP protocol. + */ + if ( $is_url_ish ) { + $lower_name = strtolower( $name ); + $enqueued_value = $this->lexical_updates[ $lower_name ]->text; + $enqueued_value = substr( $enqueued_value, strpos( $enqueued_value, '"' ) + 1, -1 ); + $enqueued_value = WP_HTML_Decoder::decode_attribute( $enqueued_value ); + $had_no_prefix = 1 !== preg_match( '~^[a-z][a-z0-9-]://~i', $given_value ); + $has_prefix = 1 === preg_match( '~^https?://~', $enqueued_value ); + + if ( $had_no_prefix && $has_prefix ) { + $escaped = strtr( + $value, + array( + '<' => '<', + '>' => '>', + '&' => '&', + '"' => '"', + "'" => ''', + ) + ); + $this->lexical_updates[ $lower_name ]->text = " {$lower_name}=\"{$escaped}\""; + } + } + + return true; + } + + private function could_potentially_escape_foreign_content() { + $token_name = $this->get_token_name(); + $is_closer = $this->is_tag_closer(); + $namespace = $this->get_namespace(); + $self_closing = $this->has_self_closing_flag(); + + if ( + ! $is_closer && + in_array( + $token_name, + array( + 'B', + 'BIG', + 'BLOCKQUOTE', + 'BODY', + 'BR', + 'CENTER', + 'CODE', + 'DD', + 'DIV', + 'DL', + 'DT', + 'EM', + 'EMBED', + 'H1', + 'H2', + 'H3', + 'H4', + 'H5', + 'H6', + 'HEAD', + 'HR', + 'I', + 'IMG', + 'LI', + 'LISTING', + 'MENU', + 'META', + 'NOBR', + 'OL', + 'P', + 'PRE', + 'RUBY', + 'S', + 'SMALL', + 'SPAN', + 'STRONG', + 'STRIKE', + 'SUB', + 'SUP', + 'TABLE', + 'TT', + 'U', + 'UL', + 'VAR', + + /* + * This is technically only necessary when it contains one + * of the `color`, `face`, or `size` attributes, but this + * is already a conservative system so it’s okay to reject. + */ + 'FONT', + + /* + * This will be parsed as 'IMG'. (Don’t ask.) + */ + 'IMAGE', + ), + true + ) || + ( + $is_closer && + in_array( + $token_name, + array( + 'BR', + 'P', + ), + true + ) + ) + ) { + return true; + } + + if ( 'math' === $namespace && ! $self_closing ) { + if ( + in_array( + $token_name, + array( + 'MI', + 'MO', + 'MN', + 'MS', + 'MTEXT', + ), + true + ) + ) { + return true; + } + + $encoding = $this->get_attribute( 'encoding' ); + if ( + 'ANNOTATION-XML' === $token_name && + is_string( $encoding ) && + ( + 0 === strcasecmp( $encoding, 'text/html' ) || + 0 === strcasecmp( $encoding, 'application/xhtml+xml' ) + ) + ) { + return true; + } + } + + if ( + 'svg' === $namespace && + ! $is_closer && + in_array( + $token_name, + array( + 'FOREIGNOBJECT', + 'DESC', + 'TITLE', + ), + true + ) + ) { + return true; + } + + return false; + } + + /** + * Returns a sanitized copy of the input HTML. + * + * @return string Sanitized copy of given input HTML. + */ + public function sanitize() { + $template_depth = 0; + $output = ''; + $foreign_content_starts_at = PHP_INT_MAX; + + /** + * These are treated as void elements inside the HTML API + * due to the special handling of their inner text content. + */ + $special_atomic_elements = array( + 'IFRAME', + 'NOEMBED', + 'NOFRAMES', + 'SCRIPT', + 'STYLE', + 'TEXTAREA', + 'TITLE', + 'XMP', + ); + + while ( $this->next_token() ) { + $token_name = $this->get_token_name(); + $token_type = $this->get_token_type(); + $namespace = $this->get_namespace(); + $is_closer = $this->is_tag_closer(); + $text = $this->get_modifiable_text(); + $here = $this->get_span(); + + /* + * Enter the foreign content and change the parsing namespace + * so that the parser recognizes real self-closing elements. + */ + $is_svg_or_math = 'MATH' === $token_name || 'SVG' === $token_name; + $has_self_closing_flag = $this->has_self_closing_flag(); + if ( $is_svg_or_math && ! $is_closer && 'html' === $namespace ) { + $this->change_parsing_namespace( strtolower( $token_name ) ); + $namespace = $this->get_namespace(); + } + + if ( 'TEMPLATE' === $token_name && 'html' === $namespace ) { + if ( $template_depth > 0 && $is_closer ) { + --$template_depth; + } elseif ( ! $is_closer ) { + ++$template_depth; + } + } + + $skip_token = $template_depth > 0; + + switch ( $token_type ) { + case '#text': + if ( $skip_token ) { + break; + } + + $text = strtr( + $text, + array( + /* + * At this point, C0 controls exist in a decoded text node, + * and the output will be re-escaped. This means that removing + * these characters cannot join together previously-separated + * syntax characters. + */ + "\x00" => '', + "\x01" => '', + "\x02" => '', + "\x03" => '', + "\x04" => '', + "\x05" => '', + "\x06" => '', + "\x07" => '', + "\x08" => '', + "\x0B" => '', + "\x0C" => '', + "\x0E" => '', + "\x0F" => '', + "\x10" => '', + "\x11" => '', + "\x12" => '', + "\x13" => '', + "\x14" => '', + "\x15" => '', + "\x16" => '', + "\x17" => '', + "\x18" => '', + "\x19" => '', + "\x1A" => '', + "\x1B" => '', + "\x1C" => '', + "\x1D" => '', + "\x1E" => '', + "\x1F" => '', + + '<' => '<', + '&' => '&', + '>' => '>', + /* + * Keep compatibility with legacy `wp_kses()`. + * These don’t need to be escaped, but they may. + * The value in escaping them is preventing errant + * PCRE patterns from catching them. In fact, only + * the `<` and `&` are required to be escaped. + */ + // "'" => ''', + // '"' => '"', + ) + ); + + $output .= $text; + break; + + /* + * Untrusted sources should not be creating these kinds of tokens, + * so remove them entirely from the output. + */ + case '#doctype': + case '#presumptuous-tag': + case '#processing-instruction': + break; + + /* + * `wp_kses()` runs iteratively on the content inside of these tokens, + * but the content is benign in a browser. + */ + case '#comment': + if ( $skip_token ) { + break; + } + + /* + * Disallow comment types as they are more prone to cause problems + * for downstream parsers that might mistake them for non-comments. + */ + if ( WP_HTML_Tag_Processor::COMMENT_AS_HTML_COMMENT !== $this->get_comment_type() ) { + break; + } + + // Apply special filtering for block comment delimiters with JSON attributes. + $comment = substr( $this->html, $here->start, $here->length ); + $block_processor = new WP_Block_Processor( $comment ); + if ( $block_processor->next_token() && $block_processor->opens_block() ) { + $original_attributes = $block_processor->allocate_and_return_parsed_attributes(); + + if ( isset( $original_attributes ) ) { + $block_type = $block_processor->get_block_type(); + $filtered_attributed = filter_block_kses_value( + $original_attributes, + $this->allowed_html, + $this->allowed_protocols, + array( 'blockName' => $block_type ) + ); + + if ( $original_attributes !== $filtered_attributed ) { + $serialized_attributes = serialize_block_attributes( $filtered_attributed ); + $voider = WP_Block_Processor::VOID === $block_processor->get_delimiter_type() ? '/' : ''; + $text = " wp:{$block_type} {$serialized_attributes} {$voider}"; + } + } + } + + /* + * Legacy `wp_kses()` recursively calls itself on the contents of comments. + * Since comment content is not escaped, this changes the meaning of those + * comments when parsed. Still, code often expects to find tag-like syntax + * only when they are real tags. This legacy defect is preserved to avoid + * presenting content that downstream parsers might misinterpret as markup. + */ + $text = strtr( $text, array( '<' => '<' ) ); + + $output .= ""; + break; + + /* + * True CDATA sections only exist within embedded SVG and MathML content, + * where they represent text data without any escaping, and where downstream + * parsers are generally reliable enough. In fact, most downstream parsers + * are more likely to properly detect true CDATA sections than the lookalikes + * that exist for elements in the HTML namespace. Copy the token verbatim. + * + * Funky comments and similar, but since they terminate at the first `>` + * character, the misparses of downstream parsers would tend to accidentally + * parse one of these properly. It’s also effective to copy verbatim. + */ + case '#cdata-section': + case '#funky-comment': + if ( ! $skip_token ) { + $output .= substr( $this->html, $here->start, $here->length ); + } + break; + + case '#tag': + /* + * Any failures inside foreign content should return the part of + * the post processed up until the entrance of the foreign content. + * This is necessary because it’s only inside foreign content that + * the self-closing flag indicates a self-closing element. + * + * While the HTML Processor can enter into SVG and MATH and track + * when they close, it’s substantially more complicated and requires + * considerable accounting. To avoid all of that, and to accept the + * kind of content that is nominal and safe, track only when the + * next tag _could_ lead to implicit changing of the parsing namespace + * or insertion mode. + */ + if ( + 'html' !== $namespace && + $this->could_potentially_escape_foreign_content() + ) { + return substr( $output, 0, $foreign_content_starts_at ); + } + + if ( $skip_token ) { + break; + } + + $tag_name = strtolower( $token_name ); + + // Skip unallowed elements by tag name + if ( ! isset( $this->allowed_html[ $tag_name ] ) ) { + break; + } + + if ( $is_closer ) { + $output .= "{$tag_name}>"; + break; + } + + $is_special_atomic_element = ( + 'html' === $namespace && + in_array( $token_name, $special_atomic_elements, true ) + ); + + $expects_closer = ! ( + 'html' === $namespace + ? ( WP_HTML_Processor::is_void( $token_name ) || $is_special_atomic_element ) + : $has_self_closing_flag + ); + + $self_closer = ( 'html' !== $namespace && $has_self_closing_flag ) ? ' /' : ''; + $closing_tag = $is_special_atomic_element ? "{$tag_name}>" : ''; + + $attribute_names = $this->get_attribute_names_with_prefix( '' ); + $element_attributes = $this->allowed_html[ $tag_name ]; + + // Check for required attributes. + $required_attributes = array(); + foreach ( $element_attributes as $name => $spec ) { + if ( true === ( $spec['required'] ?? false ) ) { + $required_attributes[ $name ] = true; + } + } + + /* + * Allow `data-*` attributes. + * + * When specifying `$allowed_html`, the attribute name should be set as + * `data-*` (not to be mixed with the HTML 4.0 `data` attribute, see + * https://www.w3.org/TR/html40/struct/objects.html#adef-data). + * + * Note: the attribute name should only contain `A-Za-z0-9_-` chars. + */ + if ( ! empty( $element_attributes['data-*'] ) ) { + foreach ( $attribute_names as $name ) { + if ( ! str_starts_with( $name, 'data-' ) ) { + continue; + } + + if ( 1 !== preg_match( '/^data-[a-z0-9_-]+$/', $name ) ) { + continue; + } + + $element_attributes[ $name ] = $element_attributes['data-*']; + } + + unset( $element_attributes['data-*'] ); + } + + $tag_maker = new self( + "<{$tag_name}{$self_closer}>{$closing_tag}", + $this->allowed_html, + $this->allowed_protocols + ); + $tag_maker->next_token(); + foreach ( $attribute_names as $name ) { + $spec = $element_attributes[ $name ] ?? null; + + // This attribute is not specified, thus not allowed. Skip it. + if ( null === $spec || '' === $spec ) { + continue; + } + + // Process the style attribute through CSS sanitization. + if ( 'style' === $name ) { + $style = safecss_filter_attr( $this->get_attribute( 'style' ) ); + $tag_maker->set_attribute( 'style', $style ); + unset( $required_attributes['style'] ); + continue; + } + + $raw_value = $this->get_attribute( $name ); + $value = is_string( $raw_value ) ? $raw_value : ''; + + /* + * Process the remaining attributes according to their policies. + * + * Non-array values for the attribute specification are assumed + * to be `true`, thus permitting the attribute. + */ + if ( is_array( $spec ) ) { + foreach ( $spec as $property => $constraint ) { + $vless = true === $raw_value ? 'y' : 'n'; + + if ( ! wp_kses_check_attr_val( $value, $vless, $property, $constraint ) ) { + continue 2; + } + } + } + + if ( true === $raw_value && '' === $value ) { + $tag_maker->set_attribute( $name, true ); + } else { + $tag_maker->set_attribute( $name, $value ); + } + unset( $required_attributes[ $name ] ); + } + + if ( ! empty( $required_attributes ) ) { + if ( ! $expects_closer ) { + break; + } + + /* + * Since this processor cannot track nesting of HTML elements + * generally, leave opening tags when required attributes are + * missing, but strip them of their attributes. + */ + $output .= "<{$tag_name}>"; + break; + } + + /* + * Track the opening of the last transition into foreign + * content so that it can be discarded when encountering + * tags that would require more substantial parsing. + */ + if ( $is_svg_or_math ) { + $foreign_content_starts_at = strlen( $output ); + } + + if ( $is_special_atomic_element ) { + $tag_maker->set_modifiable_text( $text ); + } + + $output .= $tag_maker->get_updated_html(); + break; + } + + // Re-enter the HTML namespace. + if ( $is_svg_or_math && ( $is_closer || $has_self_closing_flag ) && 'html' !== $namespace ) { + $this->change_parsing_namespace( 'html' ); + } + } + + /* + * While there might have been an incomplete token in the output stream, + * there is no need to render it to the output. They would disappear on + * their own in a browser if they ended the document, but here they do + * not end the document; instead, they are likely being inserted into an + * existing document, where the incomplete token might mess with the rest + * of the page’s HTML structure. + */ + + return $output; + } + }; + + return $processor->sanitize(); +} + /** * Filters one HTML attribute and ensures its value is allowed. * diff --git a/tests/phpunit/tests/admin/includesTemplate.php b/tests/phpunit/tests/admin/includesTemplate.php index 4b9b8bc68034e..43ff8dc2926c9 100644 --- a/tests/phpunit/tests/admin/includesTemplate.php +++ b/tests/phpunit/tests/admin/includesTemplate.php @@ -350,14 +350,14 @@ public function data_extra_args_for_add_settings_section() { ), 'disallowed tag in before_section' => array( array( - 'before_section' => '
Fallback value
' ); - $this->assertSame( - 'alert(“Unsafe HTML”)
', + $this->assertEqualHTML( + '', $content, + '', 'The post content should not include the script tag.' ); } diff --git a/tests/phpunit/tests/block-bindings/render.php b/tests/phpunit/tests/block-bindings/render.php index 3ce1993e4c351..84a7fb08b33bf 100644 --- a/tests/phpunit/tests/block-bindings/render.php +++ b/tests/phpunit/tests/block-bindings/render.php @@ -193,7 +193,7 @@ function ( $source_args, $block_instance, $attribute_name ) { function () { return ''; }, - 'alert("Unsafe HTML")
', + '', ), 'symbols and numbers should be rendered correctly' => array( function () { @@ -234,9 +234,10 @@ public function test_different_get_value_callbacks( $get_value_callback, $expect $block = new WP_Block( $parsed_blocks[0] ); $result = $block->render(); - $this->assertSame( + $this->assertEqualHTML( $expected, trim( $result ), + '', 'The block content should be updated with the value returned by the source.' ); } diff --git a/tests/phpunit/tests/customize/manager.php b/tests/phpunit/tests/customize/manager.php index 6937fcd4b2c1e..ce4991a29ae7d 100644 --- a/tests/phpunit/tests/customize/manager.php +++ b/tests/phpunit/tests/customize/manager.php @@ -1357,11 +1357,11 @@ public function test_save_changeset_post_without_kses_corrupting_json() { // User saved as one who cannot bypass content_save_pre filter. $this->assertStringNotContainsString( '' ) ); + $this->assertSame( 'Unfiltered', apply_filters( 'content_save_pre', 'Unfiltered' ) ); wp_publish_post( $changeset_post_id ); // @todo If wp_update_post() is used here, then kses will corrupt the post_content. $this->assertSame( 'Unfiltered', get_option( 'scratchpad' ) ); } diff --git a/tests/phpunit/tests/customize/nav-menu-item-setting.php b/tests/phpunit/tests/customize/nav-menu-item-setting.php index 0c832f3c6887c..df255d622880c 100644 --- a/tests/phpunit/tests/customize/nav-menu-item-setting.php +++ b/tests/phpunit/tests/customize/nav-menu-item-setting.php @@ -588,11 +588,11 @@ public function test_sanitize() { 'menu_item_parent' => 0, 'position' => -123, 'type' => 'customb', - 'title' => current_user_can( 'unfiltered_html' ) ? '\o/ o\'o Hi' : '\o/ o\'o HiunfilteredHtml()', + 'title' => current_user_can( 'unfiltered_html' ) ? '\o/ o\'o Hi' : '\o/ o\'o Hi', 'url' => '', 'target' => 'onclick', - 'attr_title' => current_user_can( 'unfiltered_html' ) ? '\o/ o\'o bolded' : '\o/ o\'o boldedunfilteredHtml()', - 'description' => current_user_can( 'unfiltered_html' ) ? '\o/ o\'o Hello world' : '\o/ o\'o Hello worldunfilteredHtml()', + 'attr_title' => current_user_can( 'unfiltered_html' ) ? '\o/ o\'o bolded' : '\o/ o\'o bolded', + 'description' => current_user_can( 'unfiltered_html' ) ? '\o/ o\'o Hello world' : '\o/ o\'o Hello world', 'classes' => 'hello inject', 'xfn' => 'hello inject', 'status' => 'draft', diff --git a/tests/phpunit/tests/formatting/sanitizeTextField.php b/tests/phpunit/tests/formatting/sanitizeTextField.php index 579f8e29de74e..664301ac8d6cd 100644 --- a/tests/phpunit/tests/formatting/sanitizeTextField.php +++ b/tests/phpunit/tests/formatting/sanitizeTextField.php @@ -20,7 +20,7 @@ public function test_sanitize_text_field( $str, $expected ) { $expected_oneline = $expected; $expected_multiline = $expected; } - $this->assertSame( $expected_oneline, sanitize_text_field( $str ) ); + $this->assertEqualHTML( $expected_oneline, sanitize_text_field( $str ) ); $this->assertSameIgnoreEOL( $expected_multiline, sanitize_textarea_field( $str ) ); } @@ -55,7 +55,7 @@ public function data_sanitize_text_field() { array( "foo <\ndiv\n> bar", array( - 'oneline' => 'foo < div > bar', + 'oneline' => 'foo < div > bar', 'multiline' => "foo <\ndiv\n> bar", ), ), diff --git a/tests/phpunit/tests/functions/wpTriggerError.php b/tests/phpunit/tests/functions/wpTriggerError.php index b642b7b08f6ae..6d577cc294fc8 100644 --- a/tests/phpunit/tests/functions/wpTriggerError.php +++ b/tests/phpunit/tests/functions/wpTriggerError.php @@ -110,7 +110,7 @@ public function data_should_trigger_error() { 'disallowed HTML elements are present in message' => array( 'function_name' => 'some_function', 'message' => '', - 'expected_message' => 'some_function(): alert("expected the function name and message")', + 'expected_message' => 'some_function(): ', ), ); } diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php index 589318daf3a70..98be614d127a5 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php @@ -417,6 +417,7 @@ public function test_updates_basic_modifiable_text_on_supported_nodes( string $h $this->assertSame( $transformed, $processor->get_updated_html(), + '', "Should have transformed the HTML as expected when modifying the target node's modifiable text." ); } diff --git a/tests/phpunit/tests/icons/wpRestIconsController.php b/tests/phpunit/tests/icons/wpRestIconsController.php index dc899ce2bd7be..dbe8c7270b263 100644 --- a/tests/phpunit/tests/icons/wpRestIconsController.php +++ b/tests/phpunit/tests/icons/wpRestIconsController.php @@ -333,11 +333,11 @@ public function test_get_item_returns_specific_icon() { $this->assertSame( 'core/arrow-left', $data['name'] ); $this->assertSame( 'Arrow Left', $data['label'] ); $this->assertNotEmpty( $data['content'] ); - $this->assertStringStartsWith( - '