diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 18f183cf3dd19..e4bf6b4e941c3 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -206,8 +206,10 @@ 'longdesc' => true, 'vspace' => true, 'src' => true, + 'srcset' => true, 'usemap' => true, 'width' => true, + 'sizes' => true, ), 'ins' => array( 'datetime' => true, @@ -250,6 +252,7 @@ 'p' => array( 'align' => true, ), + 'picture' => array(), 'pre' => array( 'width' => true, ), @@ -270,6 +273,12 @@ 'align' => true, ), 'small' => array(), + 'source' => array( + 'srcset' => true, + 'type' => true, + 'media' => true, + 'sizes' => true, + ), 'strike' => array(), 'strong' => array(), 'sub' => array(), @@ -768,7 +777,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' ) ); @@ -812,10 +820,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'; @@ -1034,6 +1039,7 @@ function wp_kses_uri_attributes() { 'src', 'usemap', 'xmlns', + 'srcset', ); /** @@ -1394,7 +1400,6 @@ function wp_kses_hair( $attr, $allowed_protocols ) { $attrarr = array(); $mode = 0; $attrname = ''; - $uris = wp_kses_uri_attributes(); // Loop through the whole attribute list. @@ -1442,9 +1447,9 @@ function wp_kses_hair( $attr, $allowed_protocols ) { if ( preg_match( '%^"([^"]*)"(\s+|/?$)%', $attr, $match ) ) { // "value" $thisval = $match[1]; - if ( in_array( strtolower( $attrname ), $uris, true ) ) { - $thisval = wp_kses_bad_protocol( $thisval, $allowed_protocols ); - } + + // Sanitize URI values. + $thisval = wp_kses_sanitize_uris( $attrname, $thisval, $allowed_protocols ); if ( false === array_key_exists( $attrname, $attrarr ) ) { $attrarr[ $attrname ] = array( @@ -1464,9 +1469,8 @@ function wp_kses_hair( $attr, $allowed_protocols ) { if ( preg_match( "%^'([^']*)'(\s+|/?$)%", $attr, $match ) ) { // 'value' $thisval = $match[1]; - if ( in_array( strtolower( $attrname ), $uris, true ) ) { - $thisval = wp_kses_bad_protocol( $thisval, $allowed_protocols ); - } + // Sanitize URI values. + $thisval = wp_kses_sanitize_uris( $attrname, $thisval, $allowed_protocols ); if ( false === array_key_exists( $attrname, $attrarr ) ) { $attrarr[ $attrname ] = array( @@ -1486,9 +1490,8 @@ function wp_kses_hair( $attr, $allowed_protocols ) { if ( preg_match( "%^([^\s\"']+)(\s+|/?$)%", $attr, $match ) ) { // value $thisval = $match[1]; - if ( in_array( strtolower( $attrname ), $uris, true ) ) { - $thisval = wp_kses_bad_protocol( $thisval, $allowed_protocols ); - } + // Sanitize URI values. + $thisval = wp_kses_sanitize_uris( $attrname, $thisval, $allowed_protocols ); if ( false === array_key_exists( $attrname, $attrarr ) ) { $attrarr[ $attrname ] = array( @@ -1530,6 +1533,42 @@ function wp_kses_hair( $attr, $allowed_protocols ) { return $attrarr; } +/** + * 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 6.9.0 + * + * @param string $attrname The attribute name to test. + * @param string $attrvalue The attribute value to sanitize. + * @param string[] $allowed_protocols Array of allowed URL protocols. + * @param string[] $multi_uri Optional. Attributes that can contain multiple URIs. Default is array( 'srcset' ). + * @return string Sanitized attribute value. + */ +function wp_kses_sanitize_uris( $attrname, $attrvalue, $allowed_protocols, $multi_uri = array( 'srcset' ) ) { + $uris = wp_kses_uri_attributes(); + + if ( ! in_array( strtolower( $attrname ), $uris, true ) ) { + return $attrvalue; + } else { + if ( in_array( strtolower( $attrname ), $multi_uri, true ) ) { + $thesevals = preg_split( '/\s*,\s*/', $attrvalue ); + } else { + $thesevals = array( $attrvalue ); + } + } + + foreach ( (array) $thesevals as $key => $val ) { + $thesevals[ $key ] = wp_kses_bad_protocol( $val, $allowed_protocols ); + } + + return implode( ', ', $thesevals ); +} + /** * Finds all attributes of an HTML element. * diff --git a/tests/phpunit/tests/kses.php b/tests/phpunit/tests/kses.php index 3384a6f137e81..7bbfda211b469 100644 --- a/tests/phpunit/tests/kses.php +++ b/tests/phpunit/tests/kses.php @@ -2394,4 +2394,240 @@ 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 ) { + if ( $name === $value ) { + $string = ""; + $expect_string = ''; + } else { + $string = ""; + $expect_string = ""; + } + + $this->assertEquals( $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->assertEquals( $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->assertEquals( $html, wp_kses( $html, $allowedposttags ) ); + + $html = 'The pear is juicy.'; + $this->assertEquals( $html, wp_kses( $html, $allowedposttags ) ); + + // Test bad protocol in srcset + $original = 'The pear is juicy.'; + $expected = 'The pear is juicy.'; + $this->assertEquals( $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->assertEquals( $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' ), + + // Test custom multi_uri parameter. + array( 'custom', 'url1.jpg, url2.jpg', 'url1.jpg, url2.jpg', array( 'custom' ) ), + ); + } + + /** + * 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->assertEquals( $expected, $result ); + } + + public function data_wp_kses_srcset_edge_cases() { + return array( + // Test an empty srcset. + array( '', '' ), + + // Srcset with extra whitespace. + array( ' image1.jpg 1x , image2.jpg 2x ', ' image1.jpg 1x, image2.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( '