diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 025a371781200..94ccca4c23b62 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -306,6 +306,7 @@ add_filter( 'teeny_mce_before_init', '_mce_set_direction' ); add_filter( 'pre_kses', 'wp_pre_kses_less_than' ); add_filter( 'pre_kses', 'wp_pre_kses_block_attributes', 10, 3 ); +add_filter( 'wp_kses_force_legacy_parser', '__return_false' ); add_filter( 'sanitize_title', 'sanitize_title_with_dashes', 10, 3 ); add_action( 'check_comment_flood', 'check_comment_flood_db', 10, 4 ); add_filter( 'comment_flood_filter', 'wp_throttle_comment_flood', 10, 3 ); diff --git a/src/wp-includes/formatting.php b/src/wp-includes/formatting.php index 5bf001c430a49..e33bd89bd3578 100644 --- a/src/wp-includes/formatting.php +++ b/src/wp-includes/formatting.php @@ -5267,10 +5267,18 @@ function wp_parse_str( $input_string, &$result ) { * * @since 2.3.0 * + * @global string $wp_kses_operating_mode Indicates if this filter should run. + * * @param string $content Text to be converted. * @return string Converted text. */ function wp_pre_kses_less_than( $content ) { + global $wp_kses_operating_mode; + + if ( 'legacy' !== ( $wp_kses_operating_mode ?? 'legacy' ) ) { + return $content; + } + return preg_replace_callback( '%<[^>]*?((?=<)|>|$)%', 'wp_pre_kses_less_than_callback', $content ); } @@ -5295,6 +5303,8 @@ function wp_pre_kses_less_than_callback( $matches ) { * * @since 5.3.1 * + * @global string $wp_kses_operating_mode Indicates if this filter should run. + * * @param string $content Content to be run through KSES. * @param array[]|string $allowed_html An array of allowed HTML elements * and attributes, or a context name @@ -5303,6 +5313,12 @@ function wp_pre_kses_less_than_callback( $matches ) { * @return string Filtered text to run through KSES. */ function wp_pre_kses_block_attributes( $content, $allowed_html, $allowed_protocols ) { + global $wp_kses_operating_mode; + + if ( 'legacy' !== ( $wp_kses_operating_mode ?? 'legacy' ) ) { + return $content; + } + /* * `filter_block_content` is expected to call `wp_kses`. Temporarily remove * the filter to avoid recursion. diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 9394b75989912..dc8380768cc3b 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -56,6 +56,17 @@ // (e.g. if using namespaces / autoload in the current PHP environment). global $allowedposttags, $allowedtags, $allowedentitynames, $allowedxmlentitynames; +/** + * Indicates which implementation of {@see \wp_kses()} is running. + * + * Nominally `legacy` unless temporarily-switched for {@see \wp_sanitize_html_kses()}. + * It’s safe to latch this into `legacy`. + * + * @global 'legacy'|'html-api' $wp_kses_operating_mode + */ +global $wp_kses_operating_mode; +$wp_kses_operating_mode = 'legacy'; + if ( ! CUSTOM_TAGS ) { /** * KSES global for default allowable HTML tags. @@ -947,18 +958,37 @@ * * @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 * + * @global string $wp_kses_operating_mode + * * @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() ) { + global $wp_kses_operating_mode; + + $wp_kses_operating_mode = 'legacy'; + + /** + * Filters whether to rely on the legacy parsing inside `wp_kses()`. + * + * @since 7.2.0 + * + * @param bool $force_legacy_parser Whether to force using the legacy parser + * instead of relying on the HTML API. + */ + if ( ! apply_filters( 'wp_kses_force_legacy_parser', true ) ) { + return wp_sanitize_html_kses( (string) $content, $allowed_html, $allowed_protocols ); + } + if ( empty( $allowed_protocols ) ) { $allowed_protocols = wp_allowed_protocols(); } @@ -970,6 +1000,872 @@ 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 7.2.0 + * + * @global string $wp_kses_operating_mode + * + * @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() ) { + global $wp_kses_operating_mode; + + $specified_allowed_html = $allowed_html; + + $allowed_protocols = empty( $allowed_protocols ) + ? wp_allowed_protocols() + : $allowed_protocols; + + // Preserve legacy behavior of stripping unwanted C0 control characters. + $content = preg_replace( '/[\x01-\x08\x0B\x0C\x0E-\x1F]/', '', $content ); + + /* + * Call legacy pre-kses filters that might have been added by plugins. + * + * Also set the operating mode to bypass the pre-filters from the legacy + * implementation of `wp_kses()`, as these filters are now run in-band + * during the processing of the input document. + * + * The reset of the operating mode should always be `legacy`, but just + * in case it isn’t, reset it to its previously-read value. + */ + try { + $previous_kses_mode = $wp_kses_operating_mode; + $wp_kses_operating_mode = 'html-api'; + $content = wp_kses_hook( $content, $specified_allowed_html, $allowed_protocols ); + } finally { + $wp_kses_operating_mode = $previous_kses_mode; + } + + $allowed_html = is_array( $allowed_html ) + ? $allowed_html + : wp_kses_allowed_html( $allowed_html ); + + /* + * 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, $specified_allowed_html, $allowed_html, $allowed_protocols, wp_kses_uri_attributes() ) extends WP_HTML_Tag_Processor { + /** + * An array of allowed HTML elements and attributes, or a context name such as 'post'. + * + * It’s important to store this alongside the resolved allowable HTML because some + * filters in some plugins look for the string values, e.g. for “post” instead of + * the resolved array, and apply logic based on that context. + * + * @see wp_kses_allowed_html() for the list of accepted context names. + * @see self::$allowed_html for the resolved array of allowable HTML elements and attributes. + * + * @since 7.2.0 + * + * @var array[]|string + */ + private $specified_allowed_html; + + /** + * An array of allowed HTML elements and attributes. + * + * This array of allowable HTML elements and attributes is resolved from the value provided + * to the sanitizer function. It’s resolved at the start to avoid repeatedly calling the + * filter stack and array-merging computations. However, it’s still necessary to carry along + * the provided context so that filters expecting the array-or-string version continue to + * operate properly. + * + * @see self::$specified_allowed_html + * + * @since 7.2.0 + * + * @var array[] + */ + private $allowed_html; + + /** + * Array of allowed URL protocols. + * + * @see \wp_allowed_protocols() + * + * @since 7.2.0 + * + * @var string[] + */ + private $allowed_protocols; + + /** + * Tracks balanced tags when inside foreign content. + * + * @since 7.2.0 + * + * @var string[] + */ + private $foreign_content_stack = array(); + + /** + * List of attributes whose values are expected to be considered URLs. + * + * @see \wp_kses_uri_attributes() + * + * @since 7.2.0 + * + * @var array + */ + private $uri_attributes; + + public function __construct( $html, $specified_allowed_html, $allowed_html, $allowed_protocols, $uri_attributes ) { + parent::__construct( $html ); + + $this->specified_allowed_html = $specified_allowed_html; + $this->allowed_html = $allowed_html; + $this->allowed_protocols = $allowed_protocols; + $this->uri_attributes = $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 { + $lower_name = strtolower( $name ); + $is_url_ish = in_array( $lower_name, $this->uri_attributes, true ); + + if ( ! $is_url_ish || ! is_string( $value ) ) { + return parent::set_attribute( $name, $value ); + } + + $escaped = strtr( + $value, + array( + '<' => '<', + '>' => '>', + '&' => '&', + '"' => '"', + "'" => ''', + ) + ); + + // Set a benign placeholder to replace below. + if ( ! parent::set_attribute( $name, true ) ) { + return false; + } + + $this->lexical_updates[ $lower_name ]->text = " {$lower_name}=\"{$escaped}\""; + + return true; + } + + private function could_escape_foreign_content( bool $is_inside_mathml_text_integration_point, bool $is_inside_svg_html_integration_point ) { + $token_name = $this->get_token_name(); + $is_closer = $this->is_tag_closer(); + $namespace = $this->get_namespace(); + $self_closing = ! $is_closer && $this->has_self_closing_flag(); + + if ( ! $is_closer && $is_inside_svg_html_integration_point ) { + return true; + } + + /* + * These two elements are excepted in HTML from the normal processing + * rules because they function in similar ways to character data. + * + * > The mglyph element is used to represent non-standard characters or + * > symbols by images; the malignmark element establishes an alignment + * > point for use within table constructs, and is otherwise invisible. + * + * They must contain no elements, so only allow self-closing tags. + */ + if ( ! $is_closer && $is_inside_mathml_text_integration_point ) { + return ! ( $self_closing && ( 'MGLYPH' === $token_name || 'MALIGNMARK' === $token_name ) ); + } + + if ( + ! $is_closer && + 'FONT' === $token_name && + ( + null !== $this->get_attribute( 'color' ) || + null !== $this->get_attribute( 'face' ) || + null !== $this->get_attribute( 'size' ) + ) + ) { + return true; + } + + 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', + ), + true + ) || + ( + $is_closer && + in_array( + $token_name, + array( + 'BR', + 'P', + ), + true + ) + ) + ) { + return true; + } + + if ( 'math' === $namespace && ! $is_closer && ! $self_closing ) { + $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; + } + + /* + * When SVG becomes a direct descendant of a MathML ANNOTATION-XML, + * the namespace remains `math` but there could be an SVG element + * with an HTML integration point. Conservatively reject any child + * SVG element inside a MathML ANNOTATION-XML to prevent this. + */ + if ( + 'SVG' === $token_name && + in_array( 'ANNOTATION-XML', $this->foreign_content_stack, true ) + ) { + return true; + } + } + + return false; + } + + /** + * Indicates if a given string contains text that would parse as a block delimiter. + * + * @since 7.2.0 + * + * @param string $text Does a block comment delimiter exist in this string value? + * @return bool Whether a block comment delimiter of any kind was found in the given string. + */ + private static function contains_a_block_delimiter( string $text ): bool { + if ( '' === $text ) { + return false; + } + + $processor = new WP_Block_Processor( $text ); + + return $processor->next_delimiter(); + } + + /** + * Returns a sanitized copy of the input HTML. + * + * @return string Sanitized copy of given input HTML. + */ + public function sanitize() { + $template_depth = 0; + $output = ''; + $special_newline_at = PHP_INT_MIN; + $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(); + $here = $this->get_span(); + + /* + * Prevent allowing NOSCRIPT elements whose parsing rules change + * based on whether the scripting flag is enabled in a browser. + * Rely on trusted inputs for producing the appropriate NOSCRIPT + * content, and prevent untrusted inputs from generating it. + */ + if ( 'NOSCRIPT' === $token_name && ! $is_closer ) { + break; + } + + $is_in_mathml_text_integration_point = ( + 'math' === $this->get_namespace() && + in_array( + end( $this->foreign_content_stack ), + array( + 'MI', + 'MN', + 'MO', + 'MS', + 'MTEXT', + ), + true + ) + ); + + $is_in_svg_html_integration_point = ( + 'svg' === $namespace && + ! $is_closer && + in_array( + end( $this->foreign_content_stack ), + array( + 'DESC', + 'FOREIGNOBJECT', + 'TITLE', + ), + true + ) + ); + + $is_in_text_integration_point = ( + $is_in_mathml_text_integration_point || + $is_in_svg_html_integration_point + ); + + /* + * While content inside integration points is generally not allowed here, + * character data inside the MathML text elements _is_ allowed. This is + * because the rules only change slightly: NULL bytes are removed instead + * of being replaced with the Unicode replacement character U+FFFD; and + * active formats are reconstructed. The format reconstruction doesn’t + * occur here but a browser will still do so; this sanitizer is generally + * unaware of nesting structure. + */ + if ( $is_in_text_integration_point && '#text' === $token_type ) { + $this->change_parsing_namespace( 'html' ); + $text = $this->get_modifiable_text(); + $this->change_parsing_namespace( $namespace ); + } else { + $text = $this->get_modifiable_text(); + } + + /* + * 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 = ! $is_closer && $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 ( 'html' !== $namespace && '#tag' === $token_type ) { + /* + * Ensure that only well-formed foreign content is allowed. + * Since un-balanced closing tags might implicitly close the + * open foreign-content element, these must be rejected. + */ + if ( $is_closer ) { + $open_element = array_pop( $this->foreign_content_stack ); + if ( null === $open_element || $token_name !== $open_element ) { + return substr( $output, 0, $foreign_content_starts_at ); + } + + /* + * Reset the foreign content tracker so it doesn’t truncate + * unintentionally after foreign content has properly closed. + */ + if ( empty( $this->foreign_content_stack ) ) { + $foreign_content_starts_at = PHP_INT_MAX; + } + } else { + /* + * 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 ( empty( $this->foreign_content_stack ) ) { + $foreign_content_starts_at = strlen( $output ); + } + + $this->foreign_content_stack[] = $token_name; + } + } + + if ( 'TEMPLATE' === $token_name && 'html' === $namespace && ! $is_closer ) { + ++$template_depth; + } + + $skip_token = ( + ( + $template_depth > 0 && + ! isset( $this->allowed_html['template'] ) + ) || + ( + ! empty( $this->foreign_content_stack ) && + ! isset( $this->allowed_html[ strtolower( $this->foreign_content_stack[0] ) ] ) + ) + ); + + switch ( $token_type ) { + case '#text': + if ( $skip_token ) { + break; + } + + $needs_special_newline = ( + strlen( $output ) === $special_newline_at && + 1 === strspn( $text, "\n\r", 0, 1 ) + ); + + $text = strtr( + $text, + array( + "\r" => ' ', + '<' => '<', + '&' => '&', + '>' => '>', + ) + ); + + if ( $needs_special_newline ) { + $output .= "\n{$text}"; + } else { + $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; + + /* + * It’s questionable whether these should be allowed through, but + * the legacy behavior supports it. Therefore, allow them as long + * as they don’t contain potentially confusing syntax characters. + */ + case '#funky-comment': + if ( ! $skip_token && ! str_contains( $text, '<' ) ) { + $output .= substr( $this->html, $here->start, $here->length ); + } + 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; + } + + /* + * There are several kinds of malformed HTML which are handled by interpreting + * them as HTML comments. For example, `` is called a “bogus comment” by the + * HTML specification, but when loaded by a browser is equivalent to ``. + * In this way, interacting with the DOM via JavaScript differs from handling + * the textual representation of a page in PHP. + * + * Ignore these non-normative comment forms to protect downstream parsers which + * might not be expecting their kinds of syntax. This prevents mis-parses for + * code which over-simplifies HTML parsing. + */ + 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 ); + + /* + * A comment like `` still appears as a normative HTML comment, + * but as an incorrectly-closed comment. Ignore these as well, as part of only + * allowing normative comment contents. + */ + $was_incorrectly_closed = '!' === $comment[ strlen( $comment ) - 2 ]; + if ( $was_incorrectly_closed ) { + break; + } + + $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_attributes = filter_block_kses_value( + $original_attributes, + $this->specified_allowed_html, + $this->allowed_protocols, + array( 'blockName' => $block_type ) + ); + + if ( $original_attributes !== $filtered_attributes ) { + // Strip the implicit `core/` prefix on serialization. + $block_type = str_starts_with( $block_type, 'core/' ) + ? substr( $block_type, /* 'core/' */ 5 ) + : $block_type; + + $serialized_attributes = serialize_block_attributes( $filtered_attributes ); + $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. However, because + * parsers tend to vary on how to parse these, for untrusted inputs, + * rewrite all CDATA sections as normal escaped text. + */ + case '#cdata-section': + if ( ! $skip_token ) { + $output .= strtr( + $text, + array( + "\x00" => "\u{FFFD}", + '<' => '<', + '&' => '&', + '>' => '>', + ) + ); + } + + 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_escape_foreign_content( + $is_in_mathml_text_integration_point, + $is_in_svg_html_integration_point + ) + ) { + 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 .= ""; + 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 ? "" : ''; + + $attribute_names = $this->get_attribute_names_with_prefix( '' ); + $element_attributes = $this->allowed_html[ $tag_name ]; + + // Check for required attributes. + $required_attributes = array(); + if ( is_array( $element_attributes ) ) { + 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-*'] ) ) { + if ( is_array( $attribute_names ) ) { + foreach ( $attribute_names as $name ) { + if ( + 1 === preg_match( '/^data-[a-z0-9_-]+$/', $name ) && + ( + ! isset( $element_attributes[ $name ] ) || + '' === $element_attributes[ $name ] + ) + ) { + $element_attributes[ $name ] = $element_attributes['data-*']; + } + } + } + + unset( $element_attributes['data-*'] ); + } + + $tag_maker = new self( + "<{$tag_name}{$self_closer}>{$closing_tag}", + $this->specified_allowed_html, + $this->allowed_html, + $this->allowed_protocols, + $this->uri_attributes + ); + $tag_maker->change_parsing_namespace( $namespace ); + $tag_maker->next_token(); + if ( is_array( $attribute_names ) ) { + 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; + } + + $raw_value = $this->get_attribute( $name ); + $value = is_string( $raw_value ) ? $raw_value : ''; + + // Process the style attribute through CSS sanitization. + if ( 'style' === $name ) { + if ( ! is_string( $raw_value ) ) { + continue; + } + + $value = safecss_filter_attr( $value ); + if ( '' === trim( $value ) ) { + continue; + } + } + + $is_url_ish = in_array( strtolower( $name ), $this->uri_attributes, true ); + if ( $is_url_ish ) { + $value = wp_kses_bad_protocol( $value, $this->allowed_protocols ); + } + + /* + * 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; + } + } + } + + $did_set = ( true === $raw_value && '' === $value ) + ? $tag_maker->set_attribute( $name, true ) + : $tag_maker->set_attribute( $name, $value ); + + if ( $did_set ) { + unset( $required_attributes[ $name ] ); + } + } + } + + $needs_special_newline = 'html' === $namespace && ( 'PRE' === $token_name || 'LISTING' === $token_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}>"; + if ( $needs_special_newline ) { + $special_newline_at = strlen( $output ); + } + break; + } + + if ( $is_special_atomic_element ) { + if ( 'TITLE' === $token_name || 'TEXTAREA' === $token_name ) { + // RCDATA nodes can be safely escaped. + $text = strtr( + $text, + array( + "\x00" => "\u{FFFD}", + "\r" => ' ', + '<' => '<', + '&' => '&', + '>' => '>', + ) + ); + + $tag_maker->set_modifiable_text( $text ); + } elseif ( ! self::contains_a_block_delimiter( $text ) ) { + // Other nodes not containing a block delimiter are safe. + $tag_maker->set_modifiable_text( $text ); + } else { + /* + * But RAWTEXT and SCRIPT cannot be generally escaped, so reject + * updates which would include something that could be misparsed + * as a block comment delimiter. + */ + $tag_maker->set_modifiable_text( '' ); + } + } + + $output .= $tag_maker->get_updated_html(); + if ( $needs_special_newline ) { + $special_newline_at = strlen( $output ); + } + + break; + } + + // Re-enter the HTML namespace. + if ( 'html' !== $namespace ) { + if ( $has_self_closing_flag ) { + array_pop( $this->foreign_content_stack ); + } + + if ( empty( $this->foreign_content_stack ) ) { + $this->change_parsing_namespace( 'html' ); + $foreign_content_starts_at = PHP_INT_MAX; + } + } + + if ( 'TEMPLATE' === $token_name && $template_depth > 0 && $is_closer && 'html' === $namespace ) { + --$template_depth; + } + } + + /* + * 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 substr( $output, 0, $foreign_content_starts_at ); + } + }; + + 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' => '
', 'after_section' => '
', ), array( 'id' => 'test-section', 'title' => 'Section title', 'callback' => '__return_false', - 'before_section' => '
', 'after_section' => '
', 'section_class' => '', ), @@ -367,14 +367,14 @@ public function data_extra_args_for_add_settings_section() { 'disallowed tag in after_section' => array( array( 'before_section' => '
', - 'after_section' => '
', ), array( 'id' => 'test-section', 'title' => 'Section title', 'callback' => '__return_false', 'before_section' => '
', - 'after_section' => '
', 'section_class' => '', ), '
', diff --git a/tests/phpunit/tests/block-bindings/postMetaSource.php b/tests/phpunit/tests/block-bindings/postMetaSource.php index 555376e1c6b0c..1c51b5cb6a67c 100644 --- a/tests/phpunit/tests/block-bindings/postMetaSource.php +++ b/tests/phpunit/tests/block-bindings/postMetaSource.php @@ -260,9 +260,10 @@ public function test_custom_field_with_unsafe_html_is_sanitized() { $content = $this->get_modified_post_content( '

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 124015557be92..bf9757a70a426 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/icons/wpIconsRegistry.php b/tests/phpunit/tests/icons/wpIconsRegistry.php index 34ba5330b6ab1..3283bcc849cf4 100644 --- a/tests/phpunit/tests/icons/wpIconsRegistry.php +++ b/tests/phpunit/tests/icons/wpIconsRegistry.php @@ -309,7 +309,7 @@ public function test_register_icon_sanitizes_content() { * @param non-falsy-string $expected The expected sanitized output. */ public function test_sanitize_icon_content( $input, $expected ) { - $this->assertSame( $expected, $this->sanitize_icon_content( $input ) ); + $this->assertEqualHTML( $expected, $this->sanitize_icon_content( $input ) ); } /** diff --git a/tests/phpunit/tests/icons/wpRestIconsController.php b/tests/phpunit/tests/icons/wpRestIconsController.php index 41b4c578f0b6b..6e83213af677f 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( - '', + array( + 'div' => array( + 'id' => true, + ), + ), + ), + ); + } + /** * Test video tag. * @@ -364,106 +409,106 @@ public function test_hackers_attacks() { switch ( $attack->name ) { case 'XSS Locator': - $this->assertSame( '\';alert(String.fromCharCode(88,83,83))//\\\';alert(String.fromCharCode(88,83,83))//";alert(String.fromCharCode(88,83,83))//\\";alert(String.fromCharCode(88,83,83))//-->">\'>alert(String.fromCharCode(88,83,83))=&{}', $result ); + $this->assertEqualHTML( '\';alert(String.fromCharCode(88,83,83))//\\\';alert(String.fromCharCode(88,83,83))//";alert(String.fromCharCode(88,83,83))//\\";alert(String.fromCharCode(88,83,83))//-->">\'>=&{}', $result ); break; case 'XSS Quick Test': - $this->assertSame( '\'\';!--"=&{()}', $result ); + $this->assertEqualHTML( '\'\';!--"=&{()}', $result ); break; case 'SCRIPT w/Alert()': - $this->assertSame( "alert('XSS')", $result ); + $this->assertEqualHTML( "alert('XSS')", $result ); break; case 'SCRIPT w/Char Code': - $this->assertSame( 'alert(String.fromCharCode(88,83,83))', $result ); + $this->assertEqualHTML( 'alert(String.fromCharCode(88,83,83))', $result ); break; case 'IMG STYLE w/expression': - $this->assertSame( 'exp/*', $result ); + $this->assertEqualHTML( 'exp/*', $result ); break; case 'List-style-image': - $this->assertSame( 'li {list-style-image: url("javascript:alert(\'XSS\')");}XSS', $result ); + $this->assertEqualHTML( 'li {list-style-image: url("javascript:alert(\'XSS\')");}XSS', $result ); break; case 'STYLE': - $this->assertSame( "alert('XSS');", $result ); + $this->assertEqualHTML( "alert('XSS');", $result ); break; case 'STYLE w/background-image': - $this->assertSame( '.XSS{background-image:url("javascript:alert(\'XSS\')");}', $result ); + $this->assertEqualHTML( '', $result ); break; case 'STYLE w/background': - $this->assertSame( 'BODY{background:url("javascript:alert(\'XSS\')")}', $result ); + $this->assertEqualHTML( 'BODY{background:url("javascript:alert(\'XSS\')")}', $result ); break; case 'Remote Stylesheet 2': - $this->assertSame( "@import'http://ha.ckers.org/xss.css';", $result ); + $this->assertEqualHTML( "@import'http://ha.ckers.org/xss.css';", $result ); break; case 'Remote Stylesheet 3': - $this->assertSame( '<META HTTP-EQUIV="Link" Content="; REL=stylesheet">', $result ); + $this->assertEqualHTML( '<META HTTP-EQUIV="Link" Content="; REL=stylesheet">', $result ); break; case 'Remote Stylesheet 4': - $this->assertSame( 'BODY{-moz-binding:url("http://ha.ckers.org/xssmoz.xml#xss")}', $result ); + $this->assertEqualHTML( 'BODY{-moz-binding:url("http://ha.ckers.org/xssmoz.xml#xss")}', $result ); break; case 'XML data island w/CDATA': - $this->assertSame( '<![CDATA[]]>', $result ); + $this->assertEqualHTML( ']]>', $result ); break; case 'XML data island w/comment': - $this->assertSame( "<IMG SRC="javascript:alert('XSS')\">", $result ); + $this->assertEqualHTML( '', $result ); break; case 'XML HTML+TIME': - $this->assertSame( '<t:set attributeName="innerHTML" to="XSSalert(\'XSS\')">', $result ); + $this->assertEqualHTML( '', $result ); break; case 'Commented-out Block': - $this->assertSame( "\nalert('XSS');", $result ); + $this->assertEqualHTML( "", $result ); break; case 'Cookie Manipulation': - $this->assertSame( '<META HTTP-EQUIV="Set-Cookie" Content="USERID=alert(\'XSS\')">', $result ); + $this->assertEqualHTML( '<META HTTP-EQUIV="Set-Cookie" Content="USERID=alert(\'XSS\')">', $result ); break; case 'SSI': - $this->assertSame( '<!--#exec cmd="/bin/echo '', $result ); + $this->assertEqualHTML( '', $result ); break; case 'PHP': - $this->assertSame( '<? echo('alert("XSS")\'); ?>', $result ); + $this->assertEqualHTML( 'alert("XSS")\'); ?>', $result ); break; case 'UTF-7 Encoding': - $this->assertSame( '+ADw-SCRIPT+AD4-alert(\'XSS\');+ADw-/SCRIPT+AD4-', $result ); + $this->assertEqualHTML( '+ADw-SCRIPT+AD4-alert(\'XSS\');+ADw-/SCRIPT+AD4-', $result ); break; case 'Escaping JavaScript escapes': - $this->assertSame( '\";alert(\'XSS\');//', $result ); + $this->assertEqualHTML( '\";alert(\'XSS\');//', $result ); break; case 'STYLE w/broken up JavaScript': - $this->assertSame( '@im\port\'\ja\vasc\ript:alert("XSS")\';', $result ); + $this->assertEqualHTML( '@im\port\'\ja\vasc\ript:alert("XSS")\';', $result ); break; case 'Null Chars 2': - $this->assertSame( '&alert("XSS")', $result ); + $this->assertEqualHTML( '&alert("XSS")', $result ); break; case 'No Closing Script Tag': - $this->assertSame( '<SCRIPT SRC=http://ha.ckers.org/xss.js', $result ); + $this->assertEqualHTML( '<SCRIPT SRC=http://ha.ckers.org/xss.js', $result ); break; case 'Half-Open HTML/JavaScript': - $this->assertSame( '<IMG SRC="javascript:alert('XSS')"', $result ); + $this->assertEqualHTML( '<IMG SRC="javascript:alert('XSS')"', $result ); break; case 'Double open angle brackets': - $this->assertSame( '<IFRAME SRC=http://ha.ckers.org/scriptlet.html <', $result ); + $this->assertEqualHTML( '<IFRAME SRC=http://ha.ckers.org/scriptlet.html <', $result ); break; case 'Extraneous Open Brackets': - $this->assertSame( '<alert("XSS");//<', $result ); + $this->assertSame( '<', $result ); break; case 'Malformed IMG Tags': - $this->assertSame( 'alert("XSS")">', $result ); + $this->assertEqualHTML( '">', $result ); break; case 'No Quotes/Semicolons': - $this->assertSame( "a=/XSS/\nalert(a.source)", $result ); + $this->assertEqualHTML( "a=/XSS/\nalert(a.source)", $result ); break; case 'Evade Regex Filter 1': - $this->assertSame( '" SRC="http://ha.ckers.org/xss.js">', $result ); + $this->assertEqualHTML( '" SRC="http://ha.ckers.org/xss.js">', $result ); break; case 'Evade Regex Filter 4': - $this->assertSame( '\'" SRC="http://ha.ckers.org/xss.js">', $result ); + $this->assertEqualHTML( '\'" SRC="http://ha.ckers.org/xss.js">', $result ); break; case 'Evade Regex Filter 5': - $this->assertSame( '` SRC="http://ha.ckers.org/xss.js">', $result ); + $this->assertEqualHTML( '` SRC="http://ha.ckers.org/xss.js">', $result ); break; case 'Filter Evasion 1': - $this->assertSame( 'document.write("<SCRI");PT SRC="http://ha.ckers.org/xss.js">', $result ); + $this->assertEqualHTML( 'PT SRC="http://ha.ckers.org/xss.js">', $result ); break; case 'Filter Evasion 2': - $this->assertSame( '\'>" SRC="http://ha.ckers.org/xss.js">', $result ); + $this->assertEqualHTML( '\'>" SRC="http://ha.ckers.org/xss.js">', $result ); break; default: $this->fail( 'KSES failed on ' . $attack->name . ': ' . $result ); @@ -854,7 +899,8 @@ public function test_wp_kses_normalize_entities( string $input, string $expected public function test_ctrl_removal( $content, $expected ) { global $allowedposttags; - return $this->assertEqualHTML( $expected, wp_kses( $content, $allowedposttags ) ); + // It also must explicitly escape the C0 control characters. + $this->assertSame( $expected, wp_kses( $content, $allowedposttags ) ); } public function data_ctrl_removal() { @@ -875,9 +921,15 @@ public function data_ctrl_removal() { "\x1Fh\x1Ee\x1Dl\x1Cl\x1Bo\x1A \x19w\x18o\x17r\x16l\x15d\x14.\x13 \x12W\x11O\x10R\x0FD\x0EP\x0CR\x0BE\x08S\x07S\x06 \x05K\x04S\X03E\x02S\x01.\x00/", 'hello world. WORDPRESS KSES./', ), + /* + * When decoding HTML, all "\r\n" grapheme clusters are converted into "\n" and + * then any remaining "\r" characters are also converted into "\n". This is why + * the two yield different outputs after normalization depending on the order in + * which they appear together. + */ array( "\t\r\n word \n\r\t", - "\t\r\n word \n\r\t", + "\t\n word \n\n\t", ), ); } @@ -891,7 +943,16 @@ public function data_ctrl_removal() { public function test_slash_zero_removal( $content, $expected ) { global $allowedposttags; - return $this->assertEqualHTML( $expected, wp_kses( $content, $allowedposttags ) ); + $with_style = array_merge( + $allowedposttags, + array( + 'style' => array( + 'type' => true, + ), + ) + ); + + return $this->assertEqualHTML( $expected, wp_kses( $content, $with_style ) ); } public function data_slash_zero_removal() { @@ -930,7 +991,7 @@ public function data_slash_zero_removal() { ), array( '', - 'div {background-image:\\0}', + '', ), ); } @@ -2347,7 +2408,7 @@ public function data_wp_kses_object_tag_allowed() { ), 'invalid value for type' => array( '', - '', + '', ), 'multiple type attributes, last invalid' => array( '', @@ -2363,39 +2424,39 @@ public function data_wp_kses_object_tag_allowed() { ), 'multiple type attributes, first invalid' => array( '', - '', + '', ), 'multiple type attributes, first upper case and invalid' => array( '', - '', + '', ), 'multiple type attributes, first invalid, last uppercase' => array( '', - '', + '', ), 'multiple object tags, last invalid' => array( '', - '', + '', ), 'multiple object tags, first invalid' => array( '', - '', + '', ), 'type attribute with partially incorrect value' => array( '', - '', + '', ), 'type attribute with empty value' => array( '', - '', + '', ), 'type attribute with no value' => array( '', - '', + '', ), 'no type attribute' => array( '', - '', + '', ), 'different protocol in url' => array( '', @@ -2403,27 +2464,27 @@ public function data_wp_kses_object_tag_allowed() { ), 'query string on url' => array( '', - '', + '', ), 'fragment on url' => array( '', - '', + '', ), 'wrong extension' => array( '', - '', + '', ), 'protocol-relative url' => array( '', - '', + '', ), 'unsupported protocol' => array( '', - '', + '', ), 'relative url' => array( '', - '', + '', ), 'url with port number-like path' => array( '', @@ -2462,11 +2523,11 @@ public function data_wp_kses_object_data_url_with_port_number_allowed() { ), 'url with wrong port number' => array( '', - '', + '', ), 'url without port number' => array( '', - '', + '', ), ); } @@ -2530,7 +2591,8 @@ public function filter_wp_kses_object_added_in_html_filter( $tags, $context ) { } /** - * Ensures that `wp_kses()` preserves various kinds of HTML comments, both valid and invalid. + * Ensures that `wp_kses()` preserves various kinds of HTML comments; + * specifically well-formed comments and “funky comments.” * * @ticket 61009 * @@ -2558,7 +2620,7 @@ public static function data_html_containing_various_kinds_of_html_comments() { return array( 'Normative HTML comment' => array( 'beforeafter', 'beforeafter' ), 'Closing tag with invalid tag name' => array( 'beforeafter', 'beforeafter' ), - 'Incorrectly opened comment (Markup declaration)' => array( 'beforeafter', 'beforeafter' ), + 'Incorrectly opened comment (Markup declaration)' => array( 'beforeafter', 'beforeafter' ), ); } diff --git a/tests/phpunit/tests/media.php b/tests/phpunit/tests/media.php index d3e57d1b747df..e9b0ef0a967a1 100644 --- a/tests/phpunit/tests/media.php +++ b/tests/phpunit/tests/media.php @@ -4721,7 +4721,7 @@ public function test_wp_filter_content_tags_does_not_lazy_load_first_image_in_bl $_wp_current_template_content = ''; $html = get_the_block_template_html(); - $this->assertSame( '
' . $expected_content . '
', $html ); + $this->assertEqualHTML( '
' . $expected_content . '
', $html ); } /** @@ -4791,7 +4791,7 @@ static function ( $attr ) { $_wp_current_template_content = ' '; $html = get_the_block_template_html(); - $this->assertSame( '
' . $expected_featured_image . '
' . $expected_content . '
', $html ); + $this->assertEqualHTML( '
' . $expected_featured_image . '
' . $expected_content . '
', $html ); } /** @@ -4850,7 +4850,7 @@ public function test_wp_filter_content_tags_does_not_lazy_load_images_in_header( $expected_template_content .= '
' . wp_img_tag_add_loading_optimization_attrs( $footer_img, 'force-lazy' ) . '
'; $html = get_the_block_template_html(); - $this->assertSame( '
' . $expected_template_content . '
', $html ); + $this->assertEqualHTML( '
' . $expected_template_content . '
', $html ); } /** @@ -5963,7 +5963,7 @@ static function ( $atts ) { // Cleanup. remove_shortcode( 'full_image' ); - $this->assertSame( $expected_content, $content ); + $this->assertEqualHTML( $expected_content, $content ); } /** @@ -6117,7 +6117,7 @@ static function ( $matches ) { remove_shortcode( 'full_image' ); unregister_block_type( 'core/full-image-shortcode' ); - $this->assertSame( $expected_content, $content ); + $this->assertEqualHTML( $expected_content, $content ); } private function reset_content_media_count() { diff --git a/tests/phpunit/tests/post/output.php b/tests/phpunit/tests/post/output.php index c1d04303161ab..94d33597c5d21 100644 --- a/tests/phpunit/tests/post/output.php +++ b/tests/phpunit/tests/post/output.php @@ -147,7 +147,7 @@ public function test_the_content_attribute_filtering() { $this->assertTrue( have_posts() ); $this->assertNull( the_post() ); - $this->assertSame( strip_ws( $expected ), strip_ws( get_echo( 'the_content' ) ) ); + $this->assertEqualHTML( strip_ws( $expected ), strip_ws( get_echo( 'the_content' ) ) ); kses_remove_filters(); } diff --git a/tests/phpunit/tests/post/wpPublishPost.php b/tests/phpunit/tests/post/wpPublishPost.php index 25fa87a71c91d..2f0818afae9c6 100644 --- a/tests/phpunit/tests/post/wpPublishPost.php +++ b/tests/phpunit/tests/post/wpPublishPost.php @@ -88,11 +88,11 @@ public function test_wp_update_post_with_content_filtering() { $post_id = wp_insert_post( array( - 'post_title' => '', + 'post_title' => 'Talking about: ', ) ); $post = get_post( $post_id ); - $this->assertSame( '', $post->post_title ); + $this->assertSame( 'Talking about: ', $post->post_title ); $this->assertSame( 'draft', $post->post_status ); kses_init_filters(); @@ -107,7 +107,7 @@ public function test_wp_update_post_with_content_filtering() { kses_remove_filters(); $post = get_post( $post->ID ); - $this->assertSame( 'Test', $post->post_title ); + $this->assertSame( 'Talking about: ', $post->post_title ); } /** diff --git a/tests/phpunit/tests/privacy/wpPrivacyGeneratePersonalDataExportGroupHtml.php b/tests/phpunit/tests/privacy/wpPrivacyGeneratePersonalDataExportGroupHtml.php index 4a2ddd6a4e0e7..81eeba9933482 100644 --- a/tests/phpunit/tests/privacy/wpPrivacyGeneratePersonalDataExportGroupHtml.php +++ b/tests/phpunit/tests/privacy/wpPrivacyGeneratePersonalDataExportGroupHtml.php @@ -174,7 +174,7 @@ public function test_disallowed_html_is_stripped() { array( 'scripts' => array( 'name' => 'Script tags are not allowed.', - 'value' => '', + 'value' => 'BeforeAfter', ), 'images' => array( 'name' => 'Images are not allowed', @@ -187,7 +187,7 @@ public function test_disallowed_html_is_stripped() { $actual = wp_privacy_generate_personal_data_export_group_html( $data, 'test-data-group', 2 ); $this->assertStringNotContainsString( $data['items'][0]['scripts']['value'], $actual ); - $this->assertStringContainsString( 'Testing that script tags are stripped.', $actual ); + $this->assertStringContainsString( 'BeforeAfter', $actual ); $this->assertStringNotContainsString( $data['items'][0]['images']['value'], $actual ); $this->assertStringContainsString( 'Images are not allowed', $actual ); diff --git a/tests/phpunit/tests/rest-api/rest-attachments-controller.php b/tests/phpunit/tests/rest-api/rest-attachments-controller.php index 282c0f6b95e22..8f39c39b252de 100644 --- a/tests/phpunit/tests/rest-api/rest-attachments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-attachments-controller.php @@ -1843,16 +1843,16 @@ public static function data_attachment_roundtrip_as_author() { // Expected returned values. array( 'title' => array( - 'raw' => 'div strong oh noes', - 'rendered' => 'div strong oh noes', + 'raw' => 'div strong ', + 'rendered' => 'div strong', ), 'description' => array( - 'raw' => '
div
strong oh noes', - 'rendered' => "
div
\n

strong oh noes

", + 'raw' => '
div
strong ', + 'rendered' => "
div
\n

strong

", ), 'caption' => array( - 'raw' => '
div
strong oh noes', - 'rendered' => "
div
\n

strong oh noes

", + 'raw' => '
div
strong ', + 'rendered' => "
div
\n

strong

", ), ), ), @@ -1897,16 +1897,16 @@ public function test_attachment_roundtrip_as_editor_unfiltered_html() { ), array( 'title' => array( - 'raw' => 'div strong oh noes', - 'rendered' => 'div strong oh noes', + 'raw' => 'div strong ', + 'rendered' => 'div strong', ), 'description' => array( - 'raw' => '
div
strong oh noes', - 'rendered' => "
div
\n

strong oh noes

", + 'raw' => '
div
strong ', + 'rendered' => "
div
\n

strong

", ), 'caption' => array( - 'raw' => '
div
strong oh noes', - 'rendered' => "
div
\n

strong oh noes

", + 'raw' => '
div
strong ', + 'rendered' => "
div
\n

strong

", ), ) ); diff --git a/tests/phpunit/tests/rest-api/rest-comments-controller.php b/tests/phpunit/tests/rest-api/rest-comments-controller.php index 97c953f6d386e..ecab04f594438 100644 --- a/tests/phpunit/tests/rest-api/rest-comments-controller.php +++ b/tests/phpunit/tests/rest-api/rest-comments-controller.php @@ -3122,8 +3122,8 @@ public function test_comment_roundtrip_as_editor_unfiltered_html() { ), array( 'content' => array( - 'raw' => 'div strong oh noes', - 'rendered' => '

div strong oh noes

', + 'raw' => 'div strong ', + 'rendered' => '

div strong

', ), 'author_name' => 'div strong', 'author_user_agent' => 'div strong', diff --git a/tests/phpunit/tests/rest-api/rest-posts-controller.php b/tests/phpunit/tests/rest-api/rest-posts-controller.php index 1a27dc17a9688..e80e9568c9570 100644 --- a/tests/phpunit/tests/rest-api/rest-posts-controller.php +++ b/tests/phpunit/tests/rest-api/rest-posts-controller.php @@ -4665,16 +4665,16 @@ public static function data_post_roundtrip_as_author() { // Expected returned values. array( 'title' => array( - 'raw' => 'div strong oh noes', - 'rendered' => 'div strong oh noes', + 'raw' => 'div strong ', + 'rendered' => 'div strong', ), 'content' => array( - 'raw' => '
div
strong oh noes', - 'rendered' => "
div
\n

strong oh noes

", + 'raw' => '
div
strong ', + 'rendered' => "
div
\n

strong

", ), 'excerpt' => array( - 'raw' => '
div
strong oh noes', - 'rendered' => "
div
\n

strong oh noes

", + 'raw' => '
div
strong ', + 'rendered' => "
div
\n

strong

", ), ), ), @@ -4717,16 +4717,16 @@ public function test_post_roundtrip_as_editor_unfiltered_html() { ), array( 'title' => array( - 'raw' => 'div strong oh noes', - 'rendered' => 'div strong oh noes', + 'raw' => 'div strong ', + 'rendered' => 'div strong', ), 'content' => array( - 'raw' => '
div
strong oh noes', - 'rendered' => "
div
\n

strong oh noes

", + 'raw' => '
div
strong ', + 'rendered' => "
div
\n

strong

", ), 'excerpt' => array( - 'raw' => '
div
strong oh noes', - 'rendered' => "
div
\n

strong oh noes

", + 'raw' => '
div
strong ', + 'rendered' => "
div
\n

strong

", ), ) ); diff --git a/tests/phpunit/tests/rest-api/rest-tags-controller.php b/tests/phpunit/tests/rest-api/rest-tags-controller.php index c12ee3b0c58e6..9b0526bad80ab 100644 --- a/tests/phpunit/tests/rest-api/rest-tags-controller.php +++ b/tests/phpunit/tests/rest-api/rest-tags-controller.php @@ -1104,7 +1104,7 @@ public function test_tag_roundtrip_as_editor_html() { ), array( 'name' => 'div strong', - 'description' => 'div strong oh noes', + 'description' => 'div strong ', ) ); } else { @@ -1116,7 +1116,7 @@ public function test_tag_roundtrip_as_editor_html() { ), array( 'name' => 'div strong', - 'description' => 'div strong oh noes', + 'description' => 'div strong ', ) ); } @@ -1149,7 +1149,7 @@ public function test_tag_roundtrip_as_superadmin_html() { ), array( 'name' => 'div strong', - 'description' => 'div strong oh noes', + 'description' => 'div strong ', ) ); } diff --git a/tests/phpunit/tests/rest-api/rest-users-controller.php b/tests/phpunit/tests/rest-api/rest-users-controller.php index 86ec4b8048551..cd4372c50b073 100644 --- a/tests/phpunit/tests/rest-api/rest-users-controller.php +++ b/tests/phpunit/tests/rest-api/rest-users-controller.php @@ -2387,7 +2387,7 @@ public function test_user_roundtrip_as_editor_html() { 'first_name' => 'div strong', 'last_name' => 'div strong', 'url' => 'http://divdiv/div%20strongstrong/strong%20scriptoh%20noes/script', - 'description' => 'div strong oh noes', + 'description' => 'div strong ', 'nickname' => 'div strong', 'password' => '
div
strong ', ) @@ -2410,7 +2410,7 @@ public function test_user_roundtrip_as_editor_html() { 'first_name' => 'div strong', 'last_name' => 'div strong', 'url' => 'http://divdiv/div%20strongstrong/strong%20scriptoh%20noes/script', - 'description' => 'div strong oh noes', + 'description' => 'div strong ', 'nickname' => 'div strong', 'password' => '
div
strong ', ) @@ -2469,7 +2469,7 @@ public function test_user_roundtrip_as_superadmin_html() { 'first_name' => 'div strong', 'last_name' => 'div strong', 'url' => 'http://divdiv/div%20strongstrong/strong%20scriptoh%20noes/script', - 'description' => 'div strong oh noes', + 'description' => 'div strong ', 'nickname' => 'div strong', 'password' => '
div
strong ', ) diff --git a/tests/phpunit/tests/rest-api/rest-widgets-controller.php b/tests/phpunit/tests/rest-api/rest-widgets-controller.php index 1a9e09dfdefa4..ed9e252dd0937 100644 --- a/tests/phpunit/tests/rest-api/rest-widgets-controller.php +++ b/tests/phpunit/tests/rest-api/rest-widgets-controller.php @@ -1254,7 +1254,7 @@ public function test_update_item_shouldnt_require_id_base() { public function test_store_html_as_admin() { if ( is_multisite() ) { $this->assertSame( - '
alert(1)
', + '
', $this->update_text_widget_with_raw_html( '' ) ); } else { diff --git a/tests/phpunit/tests/rest-api/wpRestUrlDetailsController.php b/tests/phpunit/tests/rest-api/wpRestUrlDetailsController.php index f39d9eb67e88f..134e6dc5011fc 100644 --- a/tests/phpunit/tests/rest-api/wpRestUrlDetailsController.php +++ b/tests/phpunit/tests/rest-api/wpRestUrlDetailsController.php @@ -747,7 +747,7 @@ public function test_get_description( $html, $expected ) { $method = $this->get_reflective_method( 'get_description' ); $actual = $method->invoke( $controller, $meta_elements ); - $this->assertSame( $expected, $actual ); + $this->assertEqualHTML( $expected, $actual ); } /** diff --git a/tests/phpunit/tests/widgets/wpWidgetMediaImage.php b/tests/phpunit/tests/widgets/wpWidgetMediaImage.php index 934adab6d50c9..7518e52d8b3e9 100644 --- a/tests/phpunit/tests/widgets/wpWidgetMediaImage.php +++ b/tests/phpunit/tests/widgets/wpWidgetMediaImage.php @@ -241,8 +241,8 @@ public function test_update() { $this->assertSame( $result, array( - 'caption' => '">', - ) + 'caption' => '">', + ), ); // Should return valid alt text.