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