Skip to content
Open
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
79 changes: 79 additions & 0 deletions src/wp-includes/kses.php
Original file line number Diff line number Diff line change
Expand Up @@ -3200,6 +3200,85 @@ function wp_kses_bad_protocol_once2( $scheme, $allowed_protocols ) {
}
}

/**
* Removes image candidates with a disallowed URL protocol from a `srcset` value.
*
* A `srcset` attribute holds a comma-separated list of image candidate strings,
* each a URL optionally followed by whitespace and width or density descriptors,
* for example `small.jpg 480w, large.jpg 1024w`. The value is split into candidates
* following the HTML specification's srcset parsing algorithm, so a comma only
* separates candidates when it ends a URL or its descriptors. A comma inside a URL,
* as produced by image CDN resizers, belongs to the URL, and one inside parentheses
* in the descriptors belongs to the descriptors.
*
* Each URL is checked with {@see wp_kses_bad_protocol()}. A candidate whose URL that
* check would change is removed in full, descriptors included, because stripping a
* scheme and keeping the rest would rewrite the URL rather than remove it, and
* descriptors left behind would be read as the next candidate's URL. As in
* {@see esc_url()}, a URL starting with `/` cannot carry a scheme and is kept as is.
*
* The value must be decoded attribute text, as returned by
* {@see WP_HTML_Tag_Processor::get_attribute()}, not raw HTML containing character
* references. The returned value is decoded text too; callers writing it back into
* HTML must escape it. When every candidate is allowed, the input is returned
* unchanged. Otherwise the remaining candidates are rejoined with `, `.
*
* @since 7.2.0
*
* @link https://html.spec.whatwg.org/multipage/images.html#parsing-a-srcset-attribute
*
* @param string $srcset Decoded `srcset` attribute value.
* @param string[] $allowed_protocols Array of allowed URL protocols.
* @return string The value without candidates whose URL uses a disallowed protocol.
*/
function wp_kses_sanitize_srcset( string $srcset, array $allowed_protocols ): string {
$whitespace = " \t\f\r\n";
$length = strlen( $srcset );
$at = 0;
$kept = array();
$dropped = false;

while ( $at < $length ) {
$at += strspn( $srcset, "{$whitespace},", $at );
if ( $at >= $length ) {
break;
}

// The URL is the run of non-whitespace characters, less any commas ending it.
$candidate_at = $at;
$url_length = strcspn( $srcset, $whitespace, $at );
$url = rtrim( substr( $srcset, $at, $url_length ), ',' );
$at += $url_length;

/*
* A URL ended by a comma has no descriptors. Otherwise they run to the next
* comma outside parentheses, or to the end when a parenthesis is left open.
*/
if ( strlen( $url ) === $url_length ) {
while ( $at < $length ) {
$at += strcspn( $srcset, ',(', $at );
if ( $at >= $length || ',' === $srcset[ $at ] ) {
break;
}

$closing_at = strpos( $srcset, ')', $at );
$at = false === $closing_at ? $length : $closing_at + 1;
}
$candidate = substr( $srcset, $candidate_at, $at - $candidate_at );
} else {
$candidate = $url;
}

if ( '/' === $url[0] || 0 === strcasecmp( wp_kses_bad_protocol( $url, $allowed_protocols ), $url ) ) {
$kept[] = rtrim( $candidate, $whitespace );
} else {
$dropped = true;
}
}

return $dropped ? implode( ', ', $kept ) : $srcset;
}

/**
* Converts and fixes HTML entities.
*
Expand Down
167 changes: 167 additions & 0 deletions tests/phpunit/tests/kses/wpKsesSanitizeSrcset.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
<?php
/**
* Test wp_kses_sanitize_srcset() function.
*
* @group kses
*
* @covers ::wp_kses_sanitize_srcset
*/
class Tests_Kses_WpKsesSanitizeSrcset extends WP_UnitTestCase {

/**
* Allowed protocols used by every test.
*
* @var string[]
*/
protected array $allowed_protocols;

public function set_up(): void {
parent::set_up();
$this->allowed_protocols = wp_allowed_protocols();
}

/**
* @ticket 29807
*
* @dataProvider data_allowed_values_are_returned_unchanged
*
* @param string $srcset Value whose candidates are all allowed.
*/
public function test_allowed_values_are_returned_unchanged( string $srcset ): void {
$this->assertSame( $srcset, wp_kses_sanitize_srcset( $srcset, $this->allowed_protocols ) );
}

/**
* @return array<string, array{string}>
*/
public function data_allowed_values_are_returned_unchanged(): array {
return array(
'empty value' => array( '' ),
'whitespace only' => array( " \t\n" ),
'single URL, no descriptor' => array( 'image.jpg' ),
'relative URLs' => array( 'path/to/test.png 1x, path/to/test-2x.png 2x' ),
'root-relative URLs' => array( '/test.png 1x, /test-2x.png 2x' ),
'protocol-relative URLs' => array( '//cdn.example.com/img-300.jpg 300w, //cdn.example.com/img-600.jpg 600w' ),
'absolute URLs with width descriptors' => array( 'https://example.com/small.jpg 480w, https://example.com/medium.jpg 800w, https://example.com/large.jpg 1200w' ),
'uppercase scheme' => array( 'HTTPS://example.com/a.jpg 1x, Http://example.com/b.jpg 2x' ),
'decimal density descriptors' => array( 'image-1x.jpg 1x, image-1.5x.jpg 1.5x, image-2x.jpg 2x' ),
'no descriptor on the last candidate' => array( 'small.jpg 300w, medium.jpg 600w, large.jpg' ),
'no whitespace after the comma' => array( 'image1.jpg 1x,image2.jpg 2x' ),
'extra whitespace is preserved' => array( ' image1.jpg 1x , image2.jpg 2x ' ),
'newlines are preserved' => array( "image1.jpg 1x,\nimage2.jpg 2x" ),
'commas inside a URL belong to the URL' => array( 'http://localhost/test.png,big 1x, http://localhost/test.png,medium 2x' ),
'CDN resizer URLs with commas' => array( 'https://resizer.example/cdn-cgi/image/format=auto,onerror=redirect,quality=80,width=412,height=275,dpr=1,fit=crop,gravity=0.5x0.5/https://bucket.example/photo.jpg 412w, https://resizer.example/cdn-cgi/image/format=auto,quality=80,width=824,height=550/https://bucket.example/photo.jpg 824w' ),
'CDN resizer URL without descriptor' => array( 'https://cdn.example/image/format=auto,quality=80/img.jpg 400w, https://cdn.example/image/format=auto,quality=80/img-large.jpg' ),
'root-relative URL containing a colon' => array( '/images/a:b.jpg 1x' ),
'comma inside descriptor parentheses' => array( 'a.jpg 1x(,)z, b.jpg 2x' ),
'unclosed descriptor parenthesis' => array( 'a.jpg 1x(, javascript:alert(1) 2x' ),
);
}

/**
* @ticket 29807
*
* @dataProvider data_candidates_with_disallowed_protocols_are_removed
*
* @param string $srcset Value containing at least one disallowed candidate.
* @param string $expected Value with those candidates removed.
*/
public function test_candidates_with_disallowed_protocols_are_removed( string $srcset, string $expected ): void {
$this->assertSame( $expected, wp_kses_sanitize_srcset( $srcset, $this->allowed_protocols ) );
}

/**
* @return array<string, array{string, string}>
*/
public function data_candidates_with_disallowed_protocols_are_removed(): array {
return array(
'first candidate' => array(
'bad://localhost/test.png 1x, http://localhost/test-2x.png 2x',
'http://localhost/test-2x.png 2x',
),
'last candidate' => array(
'http://localhost/test.png 1x, bad://localhost/test-2x.png 2x',
'http://localhost/test.png 1x',
),
'middle candidate' => array(
'image-1x.jpg 1x, javascript:alert(1) 1.5x, image-2x.jpg 2x',
'image-1x.jpg 1x, image-2x.jpg 2x',
),
'only candidate' => array(
'javascript:alert(1) 1x',
'',
),
'every candidate' => array(
'javascript:alert(1) 1x, data:text/html,<script>alert(1)</script> 2x',
'',
),
'two in a row' => array(
'a.jpg 1x, javascript:alert(1) 2x, vbscript:msgbox(1) 3x, d.jpg 4x',
'a.jpg 1x, d.jpg 4x',
),
'data URL' => array(
'data:image/png;base64,iVBORw0KGgo= 1x, https://example.com/a.jpg 2x',
'https://example.com/a.jpg 2x',
),
'URL with commas and a disallowed scheme' => array(
'http://localhost/test.png,big 1x, bad://localhost/test.png,medium 2x',
'http://localhost/test.png,big 1x',
),
'descriptor-less candidate after a comma' => array(
'safe.jpg, javascript:alert(1)',
'safe.jpg',
),
'comma-joined URLs form one candidate' => array(
'safe.jpg,javascript:alert(1) 2x',
'',
),
'comma attached to an invalid descriptor' => array(
'a.jpg 2q,javascript:alert(1) 2x',
'a.jpg 2q',
),
'descriptors are not sanitized as URLs' => array(
'a.jpg javascript:alert(1), b.jpg 2x',
'a.jpg javascript:alert(1), b.jpg 2x',
),
'scheme that empties under wp_kses_bad_protocol' => array(
'a:b:c:d:e:f:g 100w, safe.jpg 200w',
'safe.jpg 200w',
),
'emptied scheme without descriptor' => array(
'a:b:c:d:e:f:g, safe.jpg 200w',
'safe.jpg 200w',
),
'separators are normalized once a candidate is removed' => array(
" a.jpg 1x ,\n\tjavascript:alert(1) 2x ,b.jpg 3x ",
'a.jpg 1x, b.jpg 3x',
),
'allowed scheme is kept in its original case' => array(
'HTTPS://example.com/a.jpg 1x, javascript:alert(1) 2x',
'HTTPS://example.com/a.jpg 1x',
),
'comma inside descriptor parentheses' => array(
'a.jpg 1x(,)z,javascript:alert(1) 2x',
'a.jpg 1x(,)z',
),
'URL after descriptor parentheses that look scheme-less' => array(
'a.jpg 1x(,)/?,javascript:alert(1) 2x',
'a.jpg 1x(,)/?',
),
'unclosed parenthesis runs to the end' => array(
'javascript:alert(1) 1x(, a.jpg 2x',
'',
),
);
}

/**
* @ticket 29807
*/
public function test_respects_the_given_allowed_protocols(): void {
$srcset = 'https://example.com/a.jpg 1x, ftp://example.com/b.jpg 2x';

$this->assertSame( $srcset, wp_kses_sanitize_srcset( $srcset, array( 'https', 'ftp' ) ) );
$this->assertSame( 'https://example.com/a.jpg 1x', wp_kses_sanitize_srcset( $srcset, array( 'https' ) ) );
$this->assertSame( '', wp_kses_sanitize_srcset( $srcset, array() ) );
}
}
Loading