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 7ca5191a0f162..d74ef52aba8f7 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 @@ -4638,18 +4638,29 @@ public function set_attribute( $name, $value ): bool { * * @see https://html.spec.whatwg.org/#attributes-3 */ - $escaped_new_value = in_array( $comparable_name, wp_kses_uri_attributes(), true ) - ? esc_url( $value ) - : strtr( - $value, - array( - '<' => '<', - '>' => '>', - '&' => '&', - '"' => '"', - "'" => ''', - ) - ); + $syntax_characters = array( + '<' => '<', + '>' => '>', + '&' => '&', + '"' => '"', + "'" => ''', + ); + + if ( in_array( $comparable_name, wp_kses_multi_uri_attributes(), true ) ) { + /* + * Multi-URI attributes such as srcset contain a comma-separated list + * of URLs with optional width/density descriptors, not a single URL. + * Passing the whole value through esc_url() would encode the + * descriptor spaces and corrupt the list, so each URL in the list + * is sanitized individually before the value receives the standard + * attribute escaping. + */ + $escaped_new_value = strtr( wp_kses_sanitize_uris( $comparable_name, $value, wp_allowed_protocols() ), $syntax_characters ); + } elseif ( in_array( $comparable_name, wp_kses_uri_attributes(), true ) ) { + $escaped_new_value = esc_url( $value ); + } else { + $escaped_new_value = strtr( $value, $syntax_characters ); + } // If the escaping functions wiped out the update, reject it and indicate it was rejected. if ( '' === $escaped_new_value && '' !== $value ) { diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index d68021c3a8b30..0a5d41e8ca884 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -213,17 +213,21 @@ ), 'i' => array(), 'img' => array( - 'alt' => true, - 'align' => true, - 'border' => true, - 'height' => true, - 'hspace' => true, - 'loading' => true, - 'longdesc' => true, - 'vspace' => true, - 'src' => true, - 'usemap' => true, - 'width' => true, + 'align' => true, + 'alt' => true, + 'border' => true, + 'decoding' => true, + 'fetchpriority' => true, + 'height' => true, + 'hspace' => true, + 'loading' => true, + 'longdesc' => true, + 'sizes' => true, + 'src' => true, + 'srcset' => true, + 'usemap' => true, + 'vspace' => true, + 'width' => true, ), 'ins' => array( 'datetime' => true, @@ -274,6 +278,7 @@ 'p' => array( 'align' => true, ), + 'picture' => array(), 'pre' => array( 'width' => true, ), @@ -299,6 +304,12 @@ 'align' => true, ), 'small' => array(), + 'source' => array( + 'media' => true, + 'sizes' => true, + 'srcset' => true, + 'type' => true, + ), 'strike' => array(), 'strong' => array(), 'sub' => array(), @@ -982,7 +993,6 @@ function wp_kses( $content, $allowed_html, $allowed_protocols = array() ) { * @return string Filtered attribute. */ function wp_kses_one_attr( $attr, $element ) { - $uris = wp_kses_uri_attributes(); $allowed_html = wp_kses_allowed_html( 'post' ); $allowed_protocols = wp_allowed_protocols(); $attr = wp_kses_no_null( $attr, array( 'slash_zero' => 'keep' ) ); @@ -1026,10 +1036,7 @@ function wp_kses_one_attr( $attr, $element ) { // Sanitize quotes, angle braces, and entities. $value = esc_attr( $value ); - // Sanitize URI values. - if ( in_array( strtolower( $name ), $uris, true ) ) { - $value = wp_kses_bad_protocol( $value, $allowed_protocols ); - } + $value = wp_kses_sanitize_uris( $name, $value, $allowed_protocols ); $attr = "$name=$quote$value$quote"; $vless = 'n'; @@ -1329,6 +1336,7 @@ function wp_kses_uri_attributes() { 'poster', 'profile', 'src', + 'srcset', 'usemap', 'xmlns', ); @@ -1348,6 +1356,45 @@ function wp_kses_uri_attributes() { return $uri_attributes; } +/** + * Returns an array of HTML attribute names whose value contains a list of URLs. + * + * Unlike single-URL attributes such as `href` or `src`, these attributes hold a + * comma-separated list of URLs, each optionally followed by a descriptor, for + * example `srcset="small.jpg 480w, large.jpg 1024w"`. Their values must be split + * into individual candidates so each URL can be sanitized on its own, and must + * not be passed through `esc_url()` as a whole. + * + * An attribute in this list is sanitized as a URI attribute in its own right: + * it does not additionally need to be present in {@see wp_kses_uri_attributes()}. + * Attributes should still be added to both lists so that code consulting only + * {@see wp_kses_uri_attributes()} recognizes them as URI attributes. + * + * @since 7.1.0 + * + * @return string[] HTML attribute names whose value contains a list of URLs. + */ +function wp_kses_multi_uri_attributes() { + $multi_uri_attributes = array( + 'srcset', + ); + + /** + * Filters the list of attributes whose value contains a list of URLs. + * + * Use this filter to add attributes that, like `srcset`, contain multiple + * comma-separated URLs with optional descriptors. Attributes added here + * are sanitized per URL; also add them to the `wp_kses_uri_attributes` + * filter so that code consulting only that list recognizes them as URI + * attributes. + * + * @since 7.1.0 + * + * @param string[] $multi_uri_attributes HTML attribute names whose value contains a list of URLs. + */ + return apply_filters( 'wp_kses_multi_uri_attributes', $multi_uri_attributes ); +} + /** * Callback for `wp_kses_split()`. * @@ -1708,7 +1755,6 @@ function wp_kses_attr_check( &$name, &$value, &$whole, $vless, $element, $allowe */ function wp_kses_hair( $attr, $allowed_protocols ) { $attributes = array(); - $uris = wp_kses_uri_attributes(); $processor = new WP_HTML_Tag_Processor( "" ); $processor->next_token(); @@ -1726,11 +1772,15 @@ function wp_kses_hair( $attr, $allowed_protocols ) { '"' => '"', ); + // Look the attribute lists up once per call rather than once per attribute. + $uri_attrs = wp_kses_uri_attributes(); + $multi_uri_attrs = wp_kses_multi_uri_attributes(); + foreach ( $attribute_names as $name ) { $value = $processor->get_attribute( $name ); $is_bool = true === $value; - if ( is_string( $value ) && in_array( $name, $uris, true ) ) { - $value = wp_kses_bad_protocol( $value, $allowed_protocols ); + if ( is_string( $value ) ) { + $value = wp_kses_sanitize_uris( $name, $value, $allowed_protocols, $multi_uri_attrs, $uri_attrs ); } // Reconstruct and normalize the attribute value. @@ -1748,6 +1798,146 @@ function wp_kses_hair( $attr, $allowed_protocols ) { return $attributes; } +/** + * Sanitizes URI values in HTML attributes. + * + * This function centralizes logic for cleaning attribute values that are expected to contain URLs. + * It checks if the attribute name is one that should contain a URI (e.g., 'href', 'src', 'srcset'). + * For attributes that can contain multiple URIs (such as 'srcset'), it splits the value and sanitizes each URI individually. + * All URI values are passed through {@see wp_kses_bad_protocol()} to remove disallowed protocols (e.g., 'javascript:'). + * + * @since 7.1.0 + * + * @param string $attr_name The attribute name to test. + * @param string $attr_value The attribute value to sanitize. + * @param string[] $allowed_protocols Array of allowed URL protocols. + * @param string[]|null $multi_uri_attrs Optional. Attributes that can contain multiple URIs. + * Default null, meaning the {@see wp_kses_multi_uri_attributes()} list. + * @param string[]|null $uri_attrs Optional. Attributes that contain a single URI. + * Default null, meaning the {@see wp_kses_uri_attributes()} list. + * @return string Sanitized attribute value. + */ +function wp_kses_sanitize_uris( $attr_name, $attr_value, $allowed_protocols, $multi_uri_attrs = null, $uri_attrs = null ) { + $attr_name = strtolower( $attr_name ); + $allowed_protocols = (array) $allowed_protocols; + + if ( null === $multi_uri_attrs ) { + $multi_uri_attrs = wp_kses_multi_uri_attributes(); + } + + if ( in_array( $attr_name, $multi_uri_attrs, true ) ) { + /* + * Split the value into image candidates the same way a browser does, + * following the srcset parsing algorithm in the HTML specification: + * a candidate is a run of non-whitespace characters forming a URL, + * optionally followed by whitespace and descriptors. Commas terminate + * a URL only when they end its run of non-whitespace characters; a + * comma elsewhere in the run belongs to the URL (CDN image resizers + * produce such URLs, e.g. cdn-cgi/image/format=auto,quality=80/...). + * Descriptors end at the first comma outside parentheses. + * + * Only the URLs are sanitized. Whitespace, separating commas, and + * descriptors are preserved byte for byte, so a value containing only + * allowed URLs round-trips unchanged. + * + * @see https://html.spec.whatwg.org/multipage/images.html#parsing-a-srcset-attribute + */ + $whitespace = " \t\f\r\n"; + $length = strlen( $attr_value ); + $at = 0; + $result = ''; + + while ( $at < $length ) { + // Copy the whitespace and commas separating candidates. + $separator_length = strspn( $attr_value, "{$whitespace},", $at ); + $result .= substr( $attr_value, $at, $separator_length ); + $at += $separator_length; + + if ( $at >= $length ) { + break; + } + + // The URL is the next run of non-whitespace characters… + $url_length = strcspn( $attr_value, $whitespace, $at ); + $url = substr( $attr_value, $at, $url_length ); + $at += $url_length; + + // …except that commas ending the run terminate the URL and separate candidates. + $trimmed_url = rtrim( $url, ',' ); + $trailing_commas = strlen( $url ) - strlen( $trimmed_url ); + $url = $trimmed_url; + + /* + * Sanitize the URL's protocol only when the text before its first colon + * could be parsed as a URL scheme: an ASCII letter followed by ASCII + * letters, digits, "+", "-", and "." per RFC 3986. Browsers treat any + * other prefix as part of a schemeless, relative URL, so there is no + * protocol to check, and wp_kses_bad_protocol() would corrupt the URL + * by stripping the text through the colon: it rewrites the relative, + * same-origin URL `a.jpg,https://example.com/b.jpg` into the + * cross-origin URL `//example.com/b.jpg`. + * + * The colon detection and the scheme normalization deliberately mirror + * wp_kses_bad_protocol_once() and wp_kses_bad_protocol_once2() so no + * colon form that wp_kses_bad_protocol() would act on is missed. The + * normalization removes a superset of the characters browsers remove + * from URLs (tab, line feed, and carriage return), so any prefix + * rejected here is also rejected as a scheme by browsers. + */ + $prefix = preg_replace( '/(�*58(?![;0-9])|�*3a(?![;a-f0-9]))/i', '$1;', $url ); + $prefix = preg_split( '/:|�*58;|�*3a;|:/i', $prefix, 2 ); + + $scheme = null; + if ( isset( $prefix[1] ) ) { + $scheme = wp_kses_decode_entities( $prefix[0] ); + $scheme = preg_replace( '/\s/', '', $scheme ); + $scheme = wp_kses_no_null( $scheme ); + } + + if ( null === $scheme || '' === $scheme || preg_match( '/^[a-z][a-z0-9+.\-]*$/i', $scheme ) ) { + $url = wp_kses_bad_protocol( $url, $allowed_protocols ); + } + + $result .= $url . str_repeat( ',', $trailing_commas ); + + // A URL terminated by a comma has no descriptors. + if ( $trailing_commas > 0 ) { + continue; + } + + // Copy any descriptors verbatim: everything up to the first comma outside parentheses. + $descriptor_start = $at; + $in_parens = false; + while ( $at < $length ) { + $char = $attr_value[ $at ]; + if ( $in_parens ) { + if ( ')' === $char ) { + $in_parens = false; + } + } elseif ( '(' === $char ) { + $in_parens = true; + } elseif ( ',' === $char ) { + break; + } + ++$at; + } + $result .= substr( $attr_value, $descriptor_start, $at - $descriptor_start ); + } + + return $result; + } + + if ( null === $uri_attrs ) { + $uri_attrs = wp_kses_uri_attributes(); + } + + if ( in_array( $attr_name, $uri_attrs, true ) ) { + return wp_kses_bad_protocol( $attr_value, $allowed_protocols ); + } + + return $attr_value; +} + /** * Finds all attributes of an HTML element. * diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php index 66e01dbdbed3e..a89c314885879 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php @@ -1162,6 +1162,105 @@ public static function data_set_attribute_prevents_xss() { ); } + /** + * Ensure that setting a srcset attribute preserves the value as-is. + * + * srcset is a URI attribute per wp_kses_uri_attributes(), but unlike single-URL + * attributes it holds a comma-separated list of URLs with optional descriptors. + * Passing the whole value through esc_url() would encode the descriptor spaces + * as %20 and collapse the list into one broken URL. + * + * @ticket 29807 + * + * @covers WP_HTML_Tag_Processor::set_attribute + */ + public function test_set_attribute_preserves_srcset_value() { + $srcset = 'small.jpg 480w, medium.jpg 800w, large.jpg 2x'; + $processor = new WP_HTML_Tag_Processor( '' ); + $processor->next_tag(); + $processor->set_attribute( 'srcset', $srcset ); + + $this->assertSame( + '', + $processor->get_updated_html(), + 'set_attribute() did not preserve the srcset value verbatim' + ); + $this->assertSame( + $srcset, + $processor->get_attribute( 'srcset' ), + 'get_attribute() did not return the srcset value set via set_attribute()' + ); + } + + /** + * Ensure that set_attribute() consults wp_kses_multi_uri_attributes() rather + * than hardcoding srcset. + * + * A URI attribute normally passes through esc_url(), which would corrupt a + * srcset-style list by encoding the descriptor spaces. An attribute added to + * both the `wp_kses_uri_attributes` and `wp_kses_multi_uri_attributes` + * filters must skip esc_url() and keep its list value intact. + * + * @ticket 29807 + * + * @covers WP_HTML_Tag_Processor::set_attribute + */ + public function test_set_attribute_respects_multi_uri_attributes_filter() { + $srcset_list = 'a.jpg 1x, b.jpg 2x'; + $add_custom = static function ( $attrs ) { + $attrs[] = 'data-srcset'; + return $attrs; + }; + + // Registered only as a URI attribute, the value is passed through esc_url() and corrupted. + add_filter( 'wp_kses_uri_attributes', $add_custom ); + $processor = new WP_HTML_Tag_Processor( '' ); + $processor->next_tag(); + $processor->set_attribute( 'data-srcset', $srcset_list ); + $this->assertSame( + 'http://a.jpg%201x,%20b.jpg%202x', + $processor->get_attribute( 'data-srcset' ), + 'A single-URI attribute should receive esc_url() escaping' + ); + + // Also registered as a multi-URI attribute, the list value is preserved. + add_filter( 'wp_kses_multi_uri_attributes', $add_custom ); + $processor = new WP_HTML_Tag_Processor( '' ); + $processor->next_tag(); + $processor->set_attribute( 'data-srcset', $srcset_list ); + $this->assertSame( + $srcset_list, + $processor->get_attribute( 'data-srcset' ), + 'A multi-URI attribute must not be passed through esc_url()' + ); + + remove_filter( 'wp_kses_uri_attributes', $add_custom ); + remove_filter( 'wp_kses_multi_uri_attributes', $add_custom ); + } + + /** + * Ensure that set_attribute() sanitizes each URL in a multi-URI attribute. + * + * Skipping esc_url() must not mean skipping URL sanitization entirely: + * each srcset candidate URL is individually stripped of disallowed + * protocols while descriptors and spacing are preserved. + * + * @ticket 29807 + * + * @covers WP_HTML_Tag_Processor::set_attribute + */ + public function test_set_attribute_sanitizes_multi_uri_attribute_urls() { + $processor = new WP_HTML_Tag_Processor( '' ); + $processor->next_tag(); + $processor->set_attribute( 'srcset', 'javascript:alert(1) 1x, safe.jpg 2x' ); + + $this->assertSame( + 'alert(1) 1x, safe.jpg 2x', + $processor->get_attribute( 'srcset' ), + 'set_attribute() must strip disallowed protocols from each srcset candidate' + ); + } + /** * @ticket 56299 * diff --git a/tests/phpunit/tests/kses.php b/tests/phpunit/tests/kses.php index f560d88403524..30388a36cae9d 100644 --- a/tests/phpunit/tests/kses.php +++ b/tests/phpunit/tests/kses.php @@ -2794,4 +2794,866 @@ public function data_allowed_attributes_in_descriptions() { ), ); } + + /** + * Test that wp_filter_post_kses() filters img tags correctly and allows the srcset element. + * + * @ticket 29807 + */ + public function test_wp_filter_post_kses_img() { + global $allowedposttags; + + $attributes = array( + 'class' => 'classname', + 'id' => 'idattr', + 'style' => 'color: red;', + 'alt' => 'alt', + 'src' => '/test.png', + 'srcset' => '/test.png 1x, /test-2x.png 2x, /test-3x.png', + 'width' => '100', + 'height' => '100', + 'usemap' => '#hash', + 'vspace' => '20', + 'hspace' => '20', + 'longdesc' => 'this is the longdesc', + 'align' => 'middle', + 'border' => '5', + 'sizes' => '(max-width: 600px) 100vw, 50vw', + ); + + foreach ( $attributes as $name => $value ) { + $string = ""; + $expect_string = ''; + + $this->assertSame( $expect_string, wp_kses( $string, $allowedposttags ) ); + } + } + + /** + * @ticket 29807 + * + * @param string $unfiltered Unfiltered srcset value before wp_kses. + * @param string $expected Expected srcset value after wp_kses. + * + * @dataProvider data_wp_kses_srcset + */ + public function test_wp_kses_srcset( $unfiltered, $expected ) { + $unfiltered = ""; + $expected = ''; + $this->assertSame( $expected, wp_kses_post( $unfiltered ) ); + } + + public function data_wp_kses_srcset() { + return array( + array( + '/test.png 1x, /test-2x.png 2x', + '/test.png 1x, /test-2x.png 2x', + ), + array( + 'bad://localhost/test.png 1x, http://localhost/test-2x.png 2x', + '//localhost/test.png 1x, http://localhost/test-2x.png 2x', + ), + array( + 'http://localhost/test.png 1x, bad://localhost/test-2x.png 2x', + 'http://localhost/test.png 1x, //localhost/test-2x.png 2x', + ), + array( + 'http://localhost/test.png,big 1x, bad://localhost/test.png,medium 2x', + 'http://localhost/test.png,big 1x, //localhost/test.png,medium 2x', + ), + array( + 'path/to/test.png 1x, path/to/test-2x.png 2x', + 'path/to/test.png 1x, path/to/test-2x.png 2x', + ), + ); + } + + /** + * @ticket 29807 + */ + public function test_wp_filter_post_kses_picture() { + global $allowedposttags; + + $html = 'The pear is juicy.'; + $this->assertSame( $html, wp_kses( $html, $allowedposttags ) ); + + $html = 'The pear is juicy.'; + $this->assertSame( $html, wp_kses( $html, $allowedposttags ) ); + + // Test bad protocol in srcset. + $original = 'The pear is juicy.'; + $expected = 'The pear is juicy.'; + $this->assertSame( $expected, wp_kses( $original, $allowedposttags ) ); + } + + /** + * Test wp_kses_sanitize_uris function directly. + * + * @ticket 29807 + * @dataProvider data_wp_kses_sanitize_uris + */ + public function test_wp_kses_sanitize_uris( $attrname, $attrvalue, $expected, $multi_uri = array( 'srcset' ) ) { + $allowed_protocols = wp_allowed_protocols(); + $result = wp_kses_sanitize_uris( $attrname, $attrvalue, $allowed_protocols, $multi_uri ); + $this->assertSame( $expected, $result ); + } + + public function data_wp_kses_sanitize_uris() { + return array( + // Test non-URI attribute. + array( 'alt', 'description', 'description' ), + + // Test single URI attribute. + array( 'src', 'http://example.com/image.jpg', 'http://example.com/image.jpg' ), + + // Test single URI with bad protocol. + array( 'src', 'javascript:alert(1)', 'alert(1)' ), + + // Test srcset with multiple URIs. + array( 'srcset', 'image1.jpg 1x, image2.jpg 2x', 'image1.jpg 1x, image2.jpg 2x' ), + + // Test srcset with bad protocol. + array( 'srcset', 'javascript:alert(1) 1x, http://example.com/image.jpg 2x', 'alert(1) 1x, http://example.com/image.jpg 2x' ), + + // A custom $multi_uri_attrs entry is sanitized per URL even when the attribute + // is not registered as a URI attribute (fail-safe: one registration suffices). + array( 'custom', 'javascript:alert(1), url2.jpg', 'alert(1), url2.jpg', array( 'custom' ) ), + + // Uppercase attribute name on a single-URI attribute is normalised. + array( 'SRC', 'javascript:alert(1)', 'alert(1)' ), + + // Mixed-case attribute name on a multi-URI attribute splits correctly. + array( 'SrcSet', 'javascript:alert(1) 1x, http://example.com/image.jpg 2x', 'alert(1) 1x, http://example.com/image.jpg 2x' ), + + // Empty $multi_uri falls through to single-URI handling for a URI attribute. + // The whole value is treated as one URL, so wp_kses_bad_protocol() strips more than per-entry parsing would. + array( 'srcset', 'javascript:alert(1) 1x, http://example.com/image.jpg 2x', '//example.com/image.jpg 2x', array() ), + ); + } + + /** + * Test that a custom attribute can opt in to multi-URI sanitization. + * + * Passing the attribute in $multi_uri_attrs is sufficient for per-URL + * sanitization to apply; it does not additionally need to be registered + * via the `wp_kses_uri_attributes` filter. + * + * @ticket 29807 + * @covers ::wp_kses_sanitize_uris + */ + public function test_wp_kses_sanitize_uris_custom_multi_uri_attribute() { + $result = wp_kses_sanitize_uris( + 'data-srcset', + 'javascript:alert(1) 1x, https://example.com/img.jpg 2x', + wp_allowed_protocols(), + array( 'data-srcset' ) + ); + + $this->assertSame( 'alert(1) 1x, https://example.com/img.jpg 2x', $result ); + } + + /** + * Test edge cases for srcset sanitization. + * + * @ticket 29807 + * @dataProvider data_wp_kses_srcset_edge_cases + */ + public function test_wp_kses_srcset_edge_cases( $srcset_value, $expected ) { + $allowed_protocols = wp_allowed_protocols(); + $result = wp_kses_sanitize_uris( 'srcset', $srcset_value, $allowed_protocols ); + $this->assertSame( $expected, $result ); + } + + public function data_wp_kses_srcset_edge_cases() { + return array( + // Test an empty srcset. + array( '', '' ), + + // Srcset with extra whitespace is preserved byte for byte. + array( ' image1.jpg 1x , image2.jpg 2x ', ' image1.jpg 1x , image2.jpg 2x ' ), + + // Newlines are srcset whitespace and are preserved. + array( "image1.jpg 1x,\nimage2.jpg 2x", "image1.jpg 1x,\nimage2.jpg 2x" ), + + // Srcset with single URL and no descriptor. + array( 'image.jpg', 'image.jpg' ), + + // Srcset with complex descriptors. + array( 'small.jpg 480w, medium.jpg 800w, large.jpg 1200w', 'small.jpg 480w, medium.jpg 800w, large.jpg 1200w' ), + ); + } + + /** + * Test malicious input sanitization in srcset. + * + * @ticket 29807 + */ + public function test_wp_kses_malicious_input() { + global $allowedposttags; + + // JavaScript in srcset - the entire img tag gets escaped when it contains dangerous content. + $original = ''; + $result = wp_kses( $original, $allowedposttags ); + // The whole img tag should be escaped when it contains script content. + $this->assertStringStartsWith( '<', $result ); + + // Script tag in picture element (should be stripped). + $original = ''; + $result = wp_kses( $original, $allowedposttags ); + // Script content should be converted to text, not completely removed. + $this->assertStringContainsString( 'alert(1)', $result ); + $this->assertStringNotContainsString( '