From da95cca74cd3bfec0260ee93b179519a3e740742 Mon Sep 17 00:00:00 2001 From: whyisjake Date: Thu, 12 Dec 2019 18:18:54 +0000 Subject: [PATCH] Ensure that a user can publish_posts before making a post sticky. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Props: danielbachhuber, whyisjake, peterwilson, xknown. Prevent stored XSS through wp_targeted_link_rel(). Props: vortfu, whyisjake, peterwilsoncc, xknown, SergeyBiryukov, flaviozavan. Update `wp_kses_bad_protocol()` to recognize `:` on uri attributes, `wp_kses_bad_protocol()` makes sure to validate that uri attributes don’t contain invalid/or not allowed protocols. While this works fine in most cases, there’s a risk that by using the colon html5 named entity, one is able to bypass this function. Brings r46895 to the 5.3 branch. Props: xknown, nickdaugherty, peterwilsoncc. Prevent stored XSS in the block editor. Brings r46896 to the 5.3 branch. Prevent escaped unicode characters become unescaped in unsafe HTML during JSON decoding. Props: aduth, epiqueras. Built from https://develop.svn.wordpress.org/branches/5.2@46901 git-svn-id: http://core.svn.wordpress.org/branches/5.2@46701 1a063a9b-81f0-0310-95a4-ce76da25c4cd --- wp-includes/blocks.php | 230 +++++++++++++++++- wp-includes/default-filters.php | 1 + wp-includes/formatting.php | 96 +++++--- wp-includes/kses.php | 2 +- .../class-wp-rest-posts-controller.php | 6 +- 5 files changed, 299 insertions(+), 36 deletions(-) diff --git a/wp-includes/blocks.php b/wp-includes/blocks.php index e0c05e3b43..b0f747c02f 100644 --- a/wp-includes/blocks.php +++ b/wp-includes/blocks.php @@ -74,11 +74,11 @@ function has_blocks( $post = null ) { * @since 5.0.0 * @see parse_blocks() * - * @param string $block_type Full Block type to look for. + * @param string $block_name Full Block type to look for. * @param int|string|WP_Post|null $post Optional. Post content, post ID, or post object. Defaults to global $post. * @return bool Whether the post content contains the specified block. */ -function has_block( $block_type, $post = null ) { +function has_block( $block_name, $post = null ) { if ( ! has_blocks( $post ) ) { return false; } @@ -90,7 +90,30 @@ function has_block( $block_type, $post = null ) { } } - return false !== strpos( $post, '', $serialized_block_name, $serialized_attributes ); + } + + return sprintf( + '%s', + $serialized_block_name, + $serialized_attributes, + $block_content, + $serialized_block_name + ); +} + +/** + * Returns the content of a block, including comment delimiters, serializing all + * attributes from the given parsed block. + * + * This should be used when preparing a block to be saved to post content. + * Prefer `render_block` when preparing a block for display. Unlike + * `render_block`, this does not evaluate a block's `render_callback`, and will + * instead preserve the markup as parsed. + * + * @since 5.3.1 + * + * @param WP_Block_Parser_Block $block A single parsed block object. + * @return string String of rendered HTML. + */ +function serialize_block( $block ) { + $block_content = ''; + + $index = 0; + foreach ( $block['innerContent'] as $chunk ) { + $block_content .= is_string( $chunk ) ? $chunk : serialize_block( $block['innerBlocks'][ $index++ ] ); + } + + if ( ! is_array( $block['attrs'] ) ) { + $block['attrs'] = array(); + } + + return get_comment_delimited_block_content( + $block['blockName'], + $block['attrs'], + $block_content + ); +} + +/** + * Returns a joined string of the aggregate serialization of the given parsed + * blocks. + * + * @since 5.3.1 + * + * @param WP_Block_Parser_Block[] $blocks Parsed block objects. + * @return string String of rendered HTML. + */ +function serialize_blocks( $blocks ) { + return implode( '', array_map( 'serialize_block', $blocks ) ); +} + +/** + * Filters and sanitizes block content to remove non-allowable HTML from + * parsed block attribute values. + * + * @since 5.3.1 + * + * @param string $text Text that may contain block content. + * @param array[]|string $allowed_html An array of allowed HTML elements + * and attributes, or a context name + * such as 'post'. + * @param string[] $allowed_protocols Array of allowed URL protocols. + * @return string The filtered and sanitized content result. + */ +function filter_block_content( $text, $allowed_html = 'post', $allowed_protocols = array() ) { + $result = ''; + + $blocks = parse_blocks( $text ); + foreach ( $blocks as $block ) { + $block = filter_block_kses( $block, $allowed_html, $allowed_protocols ); + $result .= serialize_block( $block ); + } + + return $result; +} + +/** + * Filters and sanitizes a parsed block to remove non-allowable HTML from block + * attribute values. + * + * @since 5.3.1 + * + * @param WP_Block_Parser_Block $block The parsed block object. + * @param array[]|string $allowed_html An array of allowed HTML + * elements and attributes, or a + * context name such as 'post'. + * @param string[] $allowed_protocols Allowed URL protocols. + * @return array The filtered and sanitized block object result. + */ +function filter_block_kses( $block, $allowed_html, $allowed_protocols = array() ) { + $block['attrs'] = filter_block_kses_value( $block['attrs'], $allowed_html, $allowed_protocols ); + + if ( is_array( $block['innerBlocks'] ) ) { + foreach ( $block['innerBlocks'] as $i => $inner_block ) { + $block['innerBlocks'][ $i ] = filter_block_kses( $inner_block, $allowed_html, $allowed_protocols ); + } + } + + return $block; +} + +/** + * Filters and sanitizes a parsed block attribute value to remove non-allowable + * HTML. + * + * @since 5.3.1 + * + * @param mixed $value The attribute value to filter. + * @param array[]|string $allowed_html An array of allowed HTML elements + * and attributes, or a context name + * such as 'post'. + * @param string[] $allowed_protocols Array of allowed URL protocols. + * @return array The filtered and sanitized result. + */ +function filter_block_kses_value( $value, $allowed_html, $allowed_protocols = array() ) { + if ( is_array( $value ) ) { + foreach ( $value as $key => $inner_value ) { + $filtered_key = filter_block_kses_value( $key, $allowed_html, $allowed_protocols ); + $filtered_value = filter_block_kses_value( $inner_value, $allowed_html, $allowed_protocols ); + + if ( $filtered_key !== $key ) { + unset( $value[ $key ] ); + } + + $value[ $filtered_key ] = $filtered_value; + } + } elseif ( is_string( $value ) ) { + return wp_kses( $value, $allowed_html, $allowed_protocols ); + } + + return $value; +} + /** * Parses blocks out of a content string, and renders those appropriate for the excerpt. * diff --git a/wp-includes/default-filters.php b/wp-includes/default-filters.php index 056e8ffb43..9e8c6bb708 100644 --- a/wp-includes/default-filters.php +++ b/wp-includes/default-filters.php @@ -245,6 +245,7 @@ add_filter( 'option_siteurl', '_config_wp_siteurl' ); add_filter( 'tiny_mce_before_init', '_mce_set_direction' ); 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( '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/wp-includes/formatting.php b/wp-includes/formatting.php index 930f162937..737beeecb8 100644 --- a/wp-includes/formatting.php +++ b/wp-includes/formatting.php @@ -3043,8 +3043,26 @@ function wp_rel_nofollow_callback( $matches ) { */ function wp_targeted_link_rel( $text ) { // Don't run (more expensive) regex if no links with targets. - if ( stripos( $text, 'target' ) !== false && stripos( $text, ']*target\s*=[^>]*)>|i', 'wp_targeted_link_rel_callback', $text ); + if ( stripos( $text, 'target' ) === false || stripos( $text, ']*target\s*=[^>]*)>|i', 'wp_targeted_link_rel_callback', $part ); + } + + $text = ''; + for ( $i = 0; $i < count( $html_parts ); $i++ ) { + $text .= $html_parts[ $i ]; + if ( isset( $extra_parts[ $i ] ) ) { + $text .= $extra_parts[ $i ]; + } } return $text; @@ -3062,8 +3080,17 @@ function wp_targeted_link_rel( $text ) { * @return string HTML A Element with rel noreferrer noopener in addition to any existing values */ function wp_targeted_link_rel_callback( $matches ) { - $link_html = $matches[1]; - $rel_match = array(); + $link_html = $matches[1]; + $original_link_html = $link_html; + + // Consider the html escaped if there are no unescaped quotes + $is_escaped = ! preg_match( '/(^|[^\\\\])[\'"]/', $link_html ); + if ( $is_escaped ) { + // Replace only the quotes so that they are parsable by wp_kses_hair, leave the rest as is + $link_html = preg_replace( '/\\\\([\'"])/', '$1', $link_html ); + } + + $atts = wp_kses_hair( $link_html, wp_allowed_protocols() ); /** * Filters the rel values that are added to links with `target` attribute. @@ -3075,35 +3102,21 @@ function wp_targeted_link_rel_callback( $matches ) { */ $rel = apply_filters( 'wp_targeted_link_rel', 'noopener noreferrer', $link_html ); - // Avoid additional regex if the filter removes rel values. - if ( ! $rel ) { - return ""; + // Return early if no rel values to be added or if no actual target attribute + if ( ! $rel || ! isset( $atts['target'] ) ) { + return ""; } - // Value with delimiters, spaces around are optional. - $attr_regex = '|rel\s*=\s*?(\\\\{0,1}["\'])(.*?)\\1|i'; - preg_match( $attr_regex, $link_html, $rel_match ); - - if ( empty( $rel_match[0] ) ) { - // No delimiters, try with a single value and spaces, because `rel = va"lue` is totally fine... - $attr_regex = '|rel\s*=(\s*)([^\s]*)|i'; - preg_match( $attr_regex, $link_html, $rel_match ); + if ( isset( $atts['rel'] ) ) { + $all_parts = preg_split( '/\s/', "{$atts['rel']['value']} $rel", -1, PREG_SPLIT_NO_EMPTY ); + $rel = implode( ' ', array_unique( $all_parts ) ); } - if ( ! empty( $rel_match[0] ) ) { - $parts = preg_split( '|\s+|', strtolower( $rel_match[2] ) ); - $parts = array_map( 'esc_attr', $parts ); - $needed = explode( ' ', $rel ); - $parts = array_unique( array_merge( $parts, $needed ) ); - $delimiter = trim( $rel_match[1] ) ? $rel_match[1] : '"'; - $rel = 'rel=' . $delimiter . trim( implode( ' ', $parts ) ) . $delimiter; - $link_html = str_replace( $rel_match[0], $rel, $link_html ); - } elseif ( preg_match( '|target\s*=\s*?\\\\"|', $link_html ) ) { - $link_html .= " rel=\\\"$rel\\\""; - } elseif ( preg_match( '#(target|href)\s*=\s*?\'#', $link_html ) ) { - $link_html .= " rel='$rel'"; - } else { - $link_html .= " rel=\"$rel\""; + $atts['rel']['whole'] = 'rel="' . esc_attr( $rel ) . '"'; + $link_html = join( ' ', array_column( $atts, 'whole' ) ); + + if ( $is_escaped ) { + $link_html = preg_replace( '/[\'"]/', '\\\\$0', $link_html ); } return ""; @@ -4807,6 +4820,31 @@ function wp_pre_kses_less_than_callback( $matches ) { return $matches[0]; } +/** + * Remove non-allowable HTML from parsed block attribute values when filtering + * in the post context. + * + * @since 5.3.1 + * + * @param string $string Content to be run through KSES. + * @param array[]|string $allowed_html An array of allowed HTML elements + * and attributes, or a context name + * such as 'post'. + * @param string[] $allowed_protocols Array of allowed URL protocols. + * @return string Filtered text to run through KSES. + */ +function wp_pre_kses_block_attributes( $string, $allowed_html, $allowed_protocols ) { + /* + * `filter_block_content` is expected to call `wp_kses`. Temporarily remove + * the filter to avoid recursion. + */ + remove_filter( 'pre_kses', 'wp_pre_kses_block_attributes', 10 ); + $string = filter_block_content( $string, $allowed_html, $allowed_protocols ); + add_filter( 'pre_kses', 'wp_pre_kses_block_attributes', 10, 3 ); + + return $string; +} + /** * WordPress implementation of PHP sprintf() with filters. * diff --git a/wp-includes/kses.php b/wp-includes/kses.php index 8b3ff3dbc0..e2acb04f0f 100644 --- a/wp-includes/kses.php +++ b/wp-includes/kses.php @@ -1658,7 +1658,7 @@ function wp_kses_html_error( $string ) { */ function wp_kses_bad_protocol_once( $string, $allowed_protocols, $count = 1 ) { $string = preg_replace( '/(�*58(?![;0-9])|�*3a(?![;a-f0-9]))/i', '$1;', $string ); - $string2 = preg_split( '/:|�*58;|�*3a;/i', $string, 2 ); + $string2 = preg_split( '/:|�*58;|�*3a;|:/i', $string, 2 ); if ( isset( $string2[1] ) && ! preg_match( '%/\?%', $string2[0] ) ) { $string = trim( $string2[1] ); $protocol = wp_kses_bad_protocol_once2( $string2[0], $allowed_protocols ); diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php index f24bc068c8..f501302c0c 100644 --- a/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php +++ b/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php @@ -499,7 +499,7 @@ class WP_REST_Posts_Controller extends WP_REST_Controller { return new WP_Error( 'rest_cannot_edit_others', __( 'Sorry, you are not allowed to create posts as this user.' ), array( 'status' => rest_authorization_required_code() ) ); } - if ( ! empty( $request['sticky'] ) && ! current_user_can( $post_type->cap->edit_others_posts ) ) { + if ( ! empty( $request['sticky'] ) && ! current_user_can( $post_type->cap->edit_others_posts ) && ! current_user_can( $post_type->cap->publish_posts ) ) { return new WP_Error( 'rest_cannot_assign_sticky', __( 'Sorry, you are not allowed to make posts sticky.' ), array( 'status' => rest_authorization_required_code() ) ); } @@ -654,7 +654,7 @@ class WP_REST_Posts_Controller extends WP_REST_Controller { return new WP_Error( 'rest_cannot_edit_others', __( 'Sorry, you are not allowed to update posts as this user.' ), array( 'status' => rest_authorization_required_code() ) ); } - if ( ! empty( $request['sticky'] ) && ! current_user_can( $post_type->cap->edit_others_posts ) ) { + if ( ! empty( $request['sticky'] ) && ! current_user_can( $post_type->cap->edit_others_posts ) && ! current_user_can( $post_type->cap->publish_posts ) ) { return new WP_Error( 'rest_cannot_assign_sticky', __( 'Sorry, you are not allowed to make posts sticky.' ), array( 'status' => rest_authorization_required_code() ) ); } @@ -956,7 +956,7 @@ class WP_REST_Posts_Controller extends WP_REST_Controller { * @return stdClass|WP_Error Post object or WP_Error. */ protected function prepare_item_for_database( $request ) { - $prepared_post = new stdClass; + $prepared_post = new stdClass(); // Post ID. if ( isset( $request['id'] ) ) {