Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 54 additions & 15 deletions src/wp-includes/kses.php
Original file line number Diff line number Diff line change
Expand Up @@ -206,8 +206,10 @@
'longdesc' => true,
'vspace' => true,
'src' => true,
'srcset' => true,
'usemap' => true,
'width' => true,
'sizes' => true,
),
'ins' => array(
'datetime' => true,
Expand Down Expand Up @@ -250,6 +252,7 @@
'p' => array(
'align' => true,
),
'picture' => array(),
'pre' => array(
'width' => true,
),
Expand All @@ -270,6 +273,12 @@
'align' => true,
),
'small' => array(),
'source' => array(
'srcset' => true,
'type' => true,
'media' => true,
'sizes' => true,
),
'strike' => array(),
'strong' => array(),
'sub' => array(),
Expand Down Expand Up @@ -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' ) );
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -1034,6 +1039,7 @@ function wp_kses_uri_attributes() {
'src',
'usemap',
'xmlns',
'srcset',
);

/**
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -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.
*
Expand Down
236 changes: 236 additions & 0 deletions tests/phpunit/tests/kses.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<img $value />";
$expect_string = '<img ' . trim( $value, ';' ) . ' />';
} else {
$string = "<img $name='$value' />";
$expect_string = "<img $name='" . trim( $value, ';' ) . "' />";
}

$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 = "<img src='test.png' srcset='{$unfiltered}' />";
$expected = "<img src='test.png' srcset='{$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 = '<picture><source srcset="pear-mobile.jpeg" media="(max-width: 720px)" type="image/png"><source srcset="pear-tablet.jpeg" media="(max-width: 1280px)" type="image/png"><img src="pear-desktop.jpeg" alt="The pear is juicy."></picture>';
$this->assertEquals( $html, wp_kses( $html, $allowedposttags ) );

$html = '<picture><source srcset="https://wordpress.org/pear-mobile.jpeg" media="(max-width: 720px)" type="image/png"><source srcset="https://wordpress.org/pear-tablet.jpeg 500w, https://wordpress.org/pear-tablet.jpeg" media="(max-width: 1280px)" type="image/png"><img src="pear-desktop.jpeg" alt="The pear is juicy."></picture>';
$this->assertEquals( $html, wp_kses( $html, $allowedposttags ) );

// Test bad protocol in srcset
$original = '<picture><source srcset="bad://pear-mobile.jpeg" media="(max-width: 720px)" type="image/png"><source srcset="pear-tablet.jpeg" media="(max-width: 1280px)" type="image/png"><img src="pear-desktop.jpeg" alt="The pear is juicy."></picture>';
$expected = '<picture><source srcset="//pear-mobile.jpeg" media="(max-width: 720px)" type="image/png"><source srcset="pear-tablet.jpeg" media="(max-width: 1280px)" type="image/png"><img src="pear-desktop.jpeg" alt="The pear is juicy."></picture>';
$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 = '<img srcset="javascript:alert(1) 1x, data:text/html,<script>alert(1)</script> 2x" />';
$result = wp_kses( $original, $allowedposttags );
// The whole img tag should be escaped when it contains script content.
$this->assertStringStartsWith( '&lt;', $result );

// Script tag in picture element (should be stripped).
$original = '<picture><script>alert(1)</script><source srcset="image.jpg"><img src="fallback.jpg"></picture>';
$result = wp_kses( $original, $allowedposttags );
// Script content should be converted to text, not completely removed.
$this->assertStringContainsString( 'alert(1)', $result );
$this->assertStringNotContainsString( '<script>', $result );

// Onclick in source element (should be stripped)
$original = '<picture><source srcset="image.jpg" onclick="alert(1)"><img src="fallback.jpg"></picture>';
$expected = '<picture><source srcset="image.jpg"><img src="fallback.jpg"></picture>';
$this->assertEquals( $expected, wp_kses( $original, $allowedposttags ) );
}

/**
* Test sizes attribute handling.
*
* @ticket 29807
*/
public function test_wp_kses_sizes_attribute() {
global $allowedposttags;

// Valid sizes attribute.
$html = '<img src="image.jpg" sizes="(max-width: 600px) 100vw, 50vw" />';
$this->assertEquals( $html, wp_kses( $html, $allowedposttags ) );

// Complex sizes with multiple conditions.
$html = '<img src="image.jpg" sizes="(max-width: 320px) 280px, (max-width: 640px) 580px, 800px" />';
$this->assertEquals( $html, wp_kses( $html, $allowedposttags ) );

// Sizes in source element.
$html = '<picture><source srcset="mobile.jpg" sizes="100vw" media="(max-width: 600px)"><img src="desktop.jpg"></picture>';
$this->assertEquals( $html, wp_kses( $html, $allowedposttags ) );
}

/**
* Test comprehensive responsive image scenarios.
*
* @ticket 29807
*/
public function test_wp_kses_comprehensive_responsive_images() {
global $allowedposttags;

// Test complex srcset with width descriptors.
$html = '<img src="default.jpg" srcset="small.jpg 480w, medium.jpg 768w, large.jpg 1024w, xlarge.jpg 1440w" sizes="(max-width: 480px) 100vw, (max-width: 768px) 75vw, 50vw" alt="Responsive image" />';
$this->assertEquals( $html, wp_kses( $html, $allowedposttags ) );

// Test picture with multiple sources and mixed protocols.
$original = '<picture><source srcset="javascript:void(0) 480w, https://example.com/mobile.webp 480w" type="image/webp" media="(max-width: 600px)"><source srcset="bad://example.com/tablet.jpg 768w, https://example.com/tablet.jpg 768w" type="image/jpeg" media="(max-width: 1200px)"><img src="https://example.com/desktop.jpg" alt="Picture element test" /></picture>';
$result = wp_kses( $original, $allowedposttags );

// Should remove bad protocols but keep valid ones.
$this->assertStringContainsString( 'https://example.com/mobile.webp', $result );
$this->assertStringContainsString( 'https://example.com/tablet.jpg', $result );
$this->assertStringNotContainsString( 'javascript:', $result );
$this->assertStringNotContainsString( 'bad://', $result );

// Test nested picture scenario.
$original = '<picture><picture><source srcset="inner.jpg"></picture><source srcset="outer.jpg"><img src="fallback.jpg"></picture>';
$result = wp_kses( $original, $allowedposttags );
// KSES allows the nesting but should preserve the structure.
$this->assertStringContainsString( '<picture>', $result );
$this->assertStringContainsString( '<source', $result );
}
}
Loading