diff --git a/src/wp-includes/class-wp-editor.php b/src/wp-includes/class-wp-editor.php index 5d7ba224cc207..7f5ef265c6fbc 100644 --- a/src/wp-includes/class-wp-editor.php +++ b/src/wp-includes/class-wp-editor.php @@ -158,12 +158,8 @@ public static function parse_settings( $editor_id, $settings ) { */ public static function editor( $content, $editor_id, $settings = array() ) { $set = self::parse_settings( $editor_id, $settings ); - $editor_class = ' class="' . trim( esc_attr( $set['editor_class'] ) . ' wp-editor-area' ) . '"'; - $tabindex = $set['tabindex'] ? ' tabindex="' . (int) $set['tabindex'] . '"' : ''; $default_editor = 'html'; $buttons = ''; - $autocomplete = ''; - $editor_id_attr = esc_attr( $editor_id ); if ( $set['drag_drop_upload'] ) { self::$drag_drop_upload = true; @@ -180,8 +176,6 @@ public static function editor( $content, $editor_id, $settings = array() ) { } if ( self::$this_tinymce ) { - $autocomplete = ' autocomplete="off"'; - if ( self::$this_quicktags ) { $default_editor = $set['default_editor'] ? $set['default_editor'] : wp_default_editor(); // 'html' is used for the "Text" editor tab. @@ -189,10 +183,16 @@ public static function editor( $content, $editor_id, $settings = array() ) { $default_editor = 'tinymce'; } - $buttons .= '\n"; - $buttons .= '\n"; + $buttons .= WP_HTML::render( << + +HTML, + array( + 'id' => $editor_id, + 'text_label' => _x( 'Text', 'Name for the Text editor tab (formerly HTML)' ), + 'visual_label' => _x( 'Visual', 'Name for the Visual editor tab' ), + ) + ); } else { $default_editor = 'tinymce'; } @@ -201,11 +201,13 @@ public static function editor( $content, $editor_id, $settings = array() ) { $switch_class = 'html' === $default_editor ? 'html-active' : 'tmce-active'; $wrap_class = 'wp-core-ui wp-editor-wrap ' . $switch_class; - if ( $set['_content_editor_dfw'] ) { - $wrap_class .= ' has-dfw'; - } - - echo '
'; + echo WP_HTML::render( + '
', + array( + 'id' => $editor_id, + 'has_dfw' => $set['_content_editor_dfw'] ? 'has-dfw' : '', + ) + ); if ( self::$editor_buttons_css ) { wp_print_styles( 'editor-buttons' ); @@ -217,7 +219,10 @@ public static function editor( $content, $editor_id, $settings = array() ) { } if ( ! empty( $buttons ) || $set['media_buttons'] ) { - echo '
'; + echo WP_HTML::render( + '
', + array( 'id' => $editor_id ) + ); if ( $set['media_buttons'] ) { self::$has_medialib = true; @@ -226,7 +231,10 @@ public static function editor( $content, $editor_id, $settings = array() ) { require ABSPATH . 'wp-admin/includes/media.php'; } - echo '
'; + echo WP_HTML::render( + '
', + array( 'id' => $editor_id ) + ); /** * Fires after the default media button(s) are displayed. @@ -249,10 +257,13 @@ public static function editor( $content, $editor_id, $settings = array() ) { if ( 'content' === $editor_id && ! empty( $GLOBALS['current_screen'] ) && 'post' === $GLOBALS['current_screen']->base ) { $toolbar_id = 'ed_toolbar'; } else { - $toolbar_id = 'qt_' . $editor_id_attr . '_toolbar'; + $toolbar_id = 'qt_' . $editor_id . '_toolbar'; } - $quicktags_toolbar = '
'; + $quicktags_toolbar = WP_HTML::render( + '
', + array( 'id' => $toolbar_id ) + ); } /** @@ -264,10 +275,28 @@ public static function editor( $content, $editor_id, $settings = array() ) { */ $the_editor = apply_filters( 'the_editor', - '
' . + WP_HTML::render( + '
', + array( 'id' => $editor_id ) + ) . $quicktags_toolbar . - '%s
' + WP_HTML::render( + <<<'HTML' + +HTML, + array( + 'autocomplete' => self::$this_tinymce ? 'off' : null, + 'editor_class' => trim( "{$set['editor_class']} wp-editor-area" ), + 'height' => ! empty( $set['editor_height'] ) + ? array( 'style' => "height: {$set['editor_height']}px;" ) + : array( 'rows' => (string) $set['textarea_rows'] ), + 'id' => $editor_id, + 'name' => $set['textarea_name'], + 'tabindex' => $set['tabindex'] ? (string) $set['tabindex'] : null, + ) + ) . + '
' ); // Prepare the content for the Visual or Text editor, only when TinyMCE is used (back-compat). @@ -300,12 +329,16 @@ public static function editor( $content, $editor_id, $settings = array() ) { $content = apply_filters_deprecated( 'richedit_pre', array( $content ), '4.3.0', 'format_for_editor' ); } - if ( false !== stripos( $content, 'textarea' ) ) { - $content = preg_replace( '%next_tag( 'TEXTAREA' ) ) { + if ( $editor_id === $processor->get_attribute( 'id' ) ) { + $processor->set_modifiable_text( $content ); + break; + } } + $the_editor = $processor->get_updated_html(); - printf( $the_editor, $content ); - echo "\n
\n\n"; + echo "{$the_editor}\n
\n\n"; self::editor_settings( $editor_id, $set ); } diff --git a/src/wp-includes/html-api/class-wp-html-tag-processor.php b/src/wp-includes/html-api/class-wp-html-tag-processor.php index 169fabe750fcf..fa86d8a28dec6 100644 --- a/src/wp-includes/html-api/class-wp-html-tag-processor.php +++ b/src/wp-includes/html-api/class-wp-html-tag-processor.php @@ -2038,8 +2038,8 @@ private function after_tag() { $this->token_length = null; $this->tag_name_starts_at = null; $this->tag_name_length = null; - $this->text_starts_at = 0; - $this->text_length = 0; + $this->text_starts_at = null; + $this->text_length = null; $this->is_closing_tag = null; $this->attributes = array(); $this->comment_type = null; @@ -2826,6 +2826,50 @@ public function get_modifiable_text() { return $decoded; } + /** + * Sets the modifiable text for the matched token, if possible. + * + * @param string $text Replace the modifiable text with this string. + * @return bool Whether the modifiable text was updated. + */ + public function set_modifiable_text( $text ) { + if ( null === $this->text_starts_at || ! is_string( $text ) ) { + return false; + } + + switch ( $this->get_token_name() ) { + case '#text': + $this->lexical_updates[] = new WP_HTML_Text_Replacement( + $this->text_starts_at, + $this->text_length, + esc_html( $text ) + ); + break; + + case 'TEXTAREA': + $this->lexical_updates[] = new WP_HTML_Text_Replacement( + $this->text_starts_at, + $this->text_length, + preg_replace( '~lexical_updates[] = new WP_HTML_Text_Replacement( + $this->text_starts_at, + $this->text_length, + preg_replace( '~get_updated_html(); + return true; + } + /** * Updates or creates a new attribute on the currently matched tag with the passed value. * @@ -2899,14 +2943,37 @@ public function set_attribute( $name, $value ) { * > To represent a false value, the attribute has to be omitted altogether. * - HTML5 spec, https://html.spec.whatwg.org/#boolean-attributes */ - if ( false === $value ) { + if ( false === $value || null === $value ) { return $this->remove_attribute( $name ); } if ( true === $value ) { $updated_attribute = $name; } else { - $escaped_new_value = esc_attr( $value ); + $tag_name = $this->get_tag(); + $comparable_name = strtolower( $name ); + + /* + * Escape URL attributes. + * + * @see https://html.spec.whatwg.org/#attributes-3 + */ + if ( + ! str_starts_with( $value, 'data:' ) && ( + 'cite' === $comparable_name || + 'formaction' === $comparable_name || + 'href' === $comparable_name || + 'ping' === $comparable_name || + 'src' === $comparable_name || + ( 'FORM' === $tag_name && 'action' === $comparable_name ) || + ( 'OBJECT' === $tag_name && 'data' === $comparable_name ) || + ( 'VIDEO' === $tag_name && 'poster' === $comparable_name ) + ) + ) { + $escaped_new_value = esc_url( $value ); + } else { + $escaped_new_value = esc_attr( $value ); + } $updated_attribute = "{$name}=\"{$escaped_new_value}\""; } diff --git a/src/wp-includes/html-api/class-wp-html-template.php b/src/wp-includes/html-api/class-wp-html-template.php new file mode 100644 index 0000000000000..14ee1b3c670e9 --- /dev/null +++ b/src/wp-includes/html-api/class-wp-html-template.php @@ -0,0 +1,189 @@ +">', + * array( + * 'profile_url' => 'https://profiles.example.com/username', + * 'name' => $user->display_name + * ) + * ); + * // Outputs: Bobby Tables + * + * Do not escape the values supplied to the argument array! This function will escape each + * parameter's value as needed and additional manual escaping may lead to incorrect output. + * + * ## Syntax. + * + * ### Substitution Placeholders. + * + * - `` finds `named_arg` in the arguments array, escapes its value if possible, + * and replaces the placeholder with the escaped value. These may exist inside double-quoted + * HTML tag attributes or in HTML text content between tags. They cannot be used to output a tag + * name or content inside a comment. + * + * ### Spread Attributes. + * + * - `...named_arg` when found within an HTML tag will lookup `named_arg` in the arguments array + * and, if it's an array, will set the attribute on the tag for each key/value pair whose value + * is a string, boolean, or `null`. + * + * ## Notes. + * + * - Attributes may only be supplied for a limited set of types: a string value assigns a double-quoted + * attribute value; `true` sets the attribute as a boolean attribute; `null` removes the attribute. + * If provided any other type of value the attribute will be ignored and its existing value persists. + * + * - If multiple HTML attributes are specified for a given tag they will be applied as if calling + * `set_attribute()` in the order they are specified in the template. This includes any attributes + * assigned through the attribute spread syntax. + * + * - Substitutions in text nodes may only contain string values. If provided any other type of value + * the placeholder will be removed with nothing in its place. + * + * - This function currently escapes all value provided in the arguments array. In the future + * it may provide the ability to nest pre-rendered HTML into the template, but this functionality + * is deferred for a future update. + * + * - This function will not replace content inside of SCRIPT, or STYLE elements. + * + * @since 6.5.0 + * + * @access private + * + * @param string $template The HTML template. + * @param string $args Array of key/value pairs providing substitue values for the placeholders. + * @return string The rendered HTML. + */ + public static function render( $template, $args = array() ) { + $processor = new self( $template ); + while ( $processor->next_token() ) { + $type = $processor->get_token_type(); + $text = $processor->get_modifiable_text(); + + // Replace placeholders that are found inside #text nodes. + if ( '#funky-comment' === $type && strlen( $text ) > 0 && '%' === $text[0] ) { + $name = substr( $text, 1 ); + $value = isset( $args[ $name ] ) && is_string( $args[ $name ] ) ? $args[ $name ] : null; + $processor->set_bookmark( 'here' ); + $processor->lexical_updates[] = new WP_HTML_Text_Replacement( + $processor->bookmarks['here']->start, + $processor->bookmarks['here']->length, + null === $value ? '' : esc_html( $value ) + ); + continue; + } + + // For every tag, scan the attributes to look for placeholders. + if ( '#tag' === $type ) { + foreach ( $processor->get_attribute_names_with_prefix( '' ) ?? array() as $attribute_name ) { + if ( str_starts_with( $attribute_name, '...' ) ) { + $spread_name = substr( $attribute_name, 3 ); + if ( isset( $args[ $spread_name ] ) && is_array( $args[ $spread_name ] ) ) { + foreach ( $args[ $spread_name ] as $key => $value ) { + if ( true === $value || false === $value || null === $value || is_string( $value ) ) { + $processor->set_attribute( $key, $value ); + } + } + } + $processor->remove_attribute( $attribute_name ); + } + + $value = $processor->get_attribute( $attribute_name ); + + if ( ! is_string( $value ) ) { + continue; + } + + // Replace entire attributes if their content is exclusively a placeholder, e.g. `title=""`. + $full_match = null; + if ( preg_match( '~^]+)>$~', $value, $full_match ) ) { + $name = $full_match[1]; + + if ( array_key_exists( $name, $args ) ) { + $value = $args[ $name ]; + if ( false === $value || null === $value ) { + $processor->remove_attribute( $attribute_name ); + } elseif ( true === $value ) { + $processor->set_attribute( $attribute_name, true ); + } elseif ( is_string( $value ) ) { + $processor->set_attribute( $attribute_name, $args[ $name ] ); + } else { + $processor->remove_attribute( $attribute_name ); + } + } else { + $processor->remove_attribute( $attribute_name ); + } + + continue; + } + + // Replace placeholders embedded in otherwise-static attribute values, e.g. `title="Post: "`. + $new_value = preg_replace_callback( + '~]+)>~', + static function ( $matches ) use ( $args ) { + return is_string( $args[ $matches[1] ] ) + ? esc_attr( $args[ $matches[1] ] ) + : ''; + }, + $value + ); + + if ( $new_value !== $value ) { + $processor->set_attribute( $attribute_name, $new_value ); + } + } + + // Update TEXTAREA and TITLE contents. + $tag_name = $processor->get_tag(); + if ( 'TEXTAREA' === $tag_name || 'TITLE' === $tag_name ) { + // Replace placeholders inside these RCDATA tags. + $new_text = preg_replace_callback( + '~]+)>~', + static function ( $matches ) use ( $args ) { + return is_string( $args[ $matches[1] ] ) + ? $args[ $matches[1] ] + : ''; + }, + $text + ); + + if ( $new_text !== $text ) { + $processor->set_modifiable_text( $new_text ); + } + } + } + } + + return $processor->get_updated_html(); + } +} diff --git a/src/wp-includes/html-api/class-wp-html.php b/src/wp-includes/html-api/class-wp-html.php new file mode 100644 index 0000000000000..606296d2a9c7c --- /dev/null +++ b/src/wp-includes/html-api/class-wp-html.php @@ -0,0 +1,84 @@ +">', + * array( + * 'profile_url' => 'https://profiles.example.com/username', + * 'name' => $user->display_name + * ) + * ); + * // Outputs: Bobby Tables + * + * Do not escape the values supplied to the argument array! This function will escape each + * parameter's value as needed and additional manual escaping may lead to incorrect output. + * + * ## Syntax. + * + * ### Substitution Placeholders. + * + * - `` finds `named_arg` in the arguments array, escapes its value if possible, + * and replaces the placeholder with the escaped value. These may exist inside double-quoted + * HTML tag attributes or in HTML text content between tags. They cannot be used to output a tag + * name or content inside a comment. + * + * ### Spread Attributes. + * + * - `...named_arg` when found within an HTML tag will lookup `named_arg` in the arguments array + * and, if it's an array, will set the attribute on the tag for each key/value pair whose value + * is a string. The + * + * ## Notes. + * + * - Attributes may only be supplied for a limited set of types: a string value assigns a double-quoted + * attribute value; `true` sets the attribute as a boolean attribute; `null` removes the attribute. + * If provided any other type of value the attribute will be ignored and its existing value persists. + * + * - If multiple HTML attributes are specified for a given tag they will be applied as if calling + * `set_attribute()` in the order they are specified in the template. This includes any attributes + * assigned through the attribute spread syntax. + * + * - Substitutions in text nodes may only contain string values. If provided any other type of value + * the placeholder will be removed with nothing in its place. + * + * - This function currently escapes all value provided in the arguments array. In the future + * it may provide the ability to nest pre-rendered HTML into the template, but this functionality + * is deferred for a future update. + * + * - This function will not replace content inside of SCRIPT, or STYLE elements. + * + * @since 6.5.0 + * + * @access private + * + * @param string $template The HTML template. + * @param string $args Array of key/value pairs providing substitue values for the placeholders. + * @return string The rendered HTML. + */ + public static function render( $template, $args ) { + return WP_HTML_Template::render( $template, $args ); + } +} diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php index 38ec2213b7506..ecf4fd83b5cef 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -381,14 +381,10 @@ function set_post_thumbnail_size( $width = 0, $height = 0, $crop = false ) { * @return string HTML IMG element for given image attachment. */ function get_image_tag( $id, $alt, $title, $align, $size = 'medium' ) { - list( $img_src, $width, $height ) = image_downsize( $id, $size ); - $hwstring = image_hwstring( $width, $height ); - - $title = $title ? 'title="' . esc_attr( $title ) . '" ' : ''; $size_class = is_array( $size ) ? implode( 'x', $size ) : $size; - $class = 'align' . esc_attr( $align ) . ' size-' . esc_attr( $size_class ) . ' wp-image-' . $id; + $class = "align{$align} size-{$size_class} wp-image-{$id}"; /** * Filters the value of the attachment's image tag class attribute. @@ -403,7 +399,19 @@ function get_image_tag( $id, $alt, $title, $align, $size = 'medium' ) { */ $class = apply_filters( 'get_image_tag_class', $class, $id, $align, $size ); - $html = '' . esc_attr( $alt ) . ''; + $html = WP_HTML::render( + <<<'HTML' +</%alt> +HTML, + array( + 'alt' => $alt, + 'class' => $class, + 'height' => (string) $height, + 'src' => $img_src, + 'title' => empty( $title ) ? null : $title, + 'width' => (string) $width, + ) + ); /** * Filters the HTML content for the image tag. @@ -3603,37 +3611,24 @@ function wp_video_shortcode( $attr, $content = '' ) { $html_atts = array( 'class' => $atts['class'], 'id' => sprintf( 'video-%d-%d', $post_id, $instance ), - 'width' => absint( $atts['width'] ), - 'height' => absint( $atts['height'] ), - 'poster' => esc_url( $atts['poster'] ), + 'width' => (string) absint( $atts['width'] ), + 'height' => (string) absint( $atts['height'] ), + 'poster' => empty( $atts['poster'] ) ? null : $atts['poster'], 'loop' => wp_validate_boolean( $atts['loop'] ), 'autoplay' => wp_validate_boolean( $atts['autoplay'] ), 'muted' => wp_validate_boolean( $atts['muted'] ), - 'preload' => $atts['preload'], + 'preload' => empty( $atts['preload'] ) ? null : $attr['preload'], ); - // These ones should just be omitted altogether if they are blank. - foreach ( array( 'poster', 'loop', 'autoplay', 'preload', 'muted' ) as $a ) { - if ( empty( $html_atts[ $a ] ) ) { - unset( $html_atts[ $a ] ); - } - } - - $attr_strings = array(); - foreach ( $html_atts as $k => $v ) { - $attr_strings[] = $k . '="' . esc_attr( $v ) . '"'; - } - $html = ''; if ( 'mediaelement' === $library && 1 === $instance ) { $html .= "\n"; } - $html .= sprintf( '
', + array( + 'count' => 'Hi <3', + 'class' => '5>4', + 'is_inert' => 'inert', + 'div-args' => array( + 'class' => 'hoover', + 'disabled' => true, + ), + ) + ); + + $this->assertSame( + '
Just a <strong>Hi <3</strong> test
', + $html, + 'Failed to properly render template.' + ); + } + + /** + * Ensures that basic attacks on attribute names and values are blocked. + * + * @ticket 60229 + * + * @covers WP_HTML::render + */ + public function test_cannot_break_out_of_tag_with_malicious_attribute_name() { + $html = WP_HTML_Template::render( + '
', + array( + 'class' => '">', + 'args' => array( + '"> double-quoted escape' => 'busted!', + '> tag escape' => 'busted!', + ), + ) + ); + + // The output here should include an escaped `class` attribute and no others, also no other tags. + $processor = new WP_HTML_Tag_Processor( $html ); + $processor->next_tag(); + + $this->assertSame( + 'DIV', + $processor->get_tag(), + "Expected to find DIV tag but found {$processor->get_tag()} instead." + ); + + $this->assertSame( + '">', + $processor->get_attribute( 'class' ), + 'Should have found escaped `class` attribute.' + ); + + $this->assertSame( + array( 'class' ), + $processor->get_attribute_names_with_prefix( '' ), + 'Should have set `class` attribute and no others.' + ); + + $this->assertFalse( + $processor->next_tag(), + "Should not have found any other tags but found {$processor->get_tag()} instead." + ); + } + + /** + * Ensures that basic replacement inside a TEXTAREA subtitutes placeholders. + * + * @ticket 60229 + */ + public function test_replaces_textarea_placeholders() { + $html = WP_HTML_Template::render( + '', + array( 'big' => ' ()' ) + ); + + $this->assertSame( + '', + $html, + 'Should have replaced placeholder with RCDATA escaping rules.' + ); + } +}