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 = '
';
+ $this->assertEquals( $html, wp_kses( $html, $allowedposttags ) );
+
+ $html = '
';
+ $this->assertEquals( $html, wp_kses( $html, $allowedposttags ) );
+
+ // Test bad protocol in srcset
+ $original = '
';
+ $expected = '
';
+ $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( '