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
165 changes: 153 additions & 12 deletions src/wp-includes/kses.php
Original file line number Diff line number Diff line change
Expand Up @@ -1874,10 +1874,7 @@ public function sanitize() {
}
}

$is_url_ish = in_array( strtolower( $name ), $this->uri_attributes, true );
if ( $is_url_ish ) {
$value = wp_kses_bad_protocol( $value, $this->allowed_protocols );
}
$value = _wp_kses_sanitize_url_attribute( $tag_name, strtolower( $name ), $value, $this->allowed_protocols, $this->uri_attributes );

/*
* Process the remaining attributes according to their policies.
Expand Down Expand Up @@ -2093,9 +2090,14 @@ 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 );
/*
* Sanitize URL values. The value is already escaped, so it is decoded
* for sanitization and re-encoded only when sanitization changed it.
*/
$decoded = WP_HTML_Decoder::decode_attribute( $value );
$sanitized = _wp_kses_sanitize_url_attribute( strtolower( $element ), strtolower( $name ), $decoded, $allowed_protocols, $uris );
if ( $sanitized !== $decoded ) {
$value = _wp_specialchars( $sanitized, ENT_QUOTES, false, true );
}

$attr = "$name=$quote$value$quote";
Expand Down Expand Up @@ -2415,6 +2417,50 @@ function wp_kses_uri_attributes() {
return $uri_attributes;
}

/**
* Sanitizes the value of an attribute that a browser reads as a URL or a list of URLs.
*
* Most URL attributes hold a single URL and are listed by {@see wp_kses_uri_attributes()}.
* A few hold a list whose syntax depends on the element they appear on: `srcset` on
* `img` and `source`, and `imagesrcset` on `link`, hold a comma-separated list of
* image candidate strings and are handled by {@see wp_kses_sanitize_srcset()}. On any
* other element a browser ignores those attributes, so they are left alone.
*
* Takes and returns decoded attribute text.
*
* @since 7.2.0
* @access private
*
* @param string $element Lowercase element name, or an empty string when unknown.
* @param string $attribute Lowercase attribute name.
* @param string $value Decoded attribute value.
* @param string[] $allowed_protocols Array of allowed URL protocols.
* @param string[] $uri_attributes Names of attributes holding a single URL,
* as returned by wp_kses_uri_attributes().
* @return string Sanitized value.
*/
function _wp_kses_sanitize_url_attribute( string $element, string $attribute, string $value, array $allowed_protocols, array $uri_attributes ): string {
switch ( $attribute ) {
case 'srcset':
if ( '' === $element || 'img' === $element || 'source' === $element ) {
return wp_kses_sanitize_srcset( $value, $allowed_protocols );
}
break;

case 'imagesrcset':
if ( '' === $element || 'link' === $element ) {
return wp_kses_sanitize_srcset( $value, $allowed_protocols );
}
break;
}

if ( in_array( $attribute, $uri_attributes, true ) ) {
return wp_kses_bad_protocol( $value, $allowed_protocols );
}

return $value;
}

/**
* Callback for `wp_kses_split()`.
*
Expand Down Expand Up @@ -2604,7 +2650,7 @@ function wp_kses_attr( $element, $attr, $allowed_html, $allowed_protocols ) {
}

// Split it.
$attrarr = wp_kses_hair( $attr, $allowed_protocols );
$attrarr = wp_kses_hair( $attr, $allowed_protocols, $element );

// Check if there are attributes that are required.
$required_attrs = array_filter(
Expand Down Expand Up @@ -2746,7 +2792,7 @@ function wp_kses_attr_check( &$name, &$value, &$whole, $vless, $element, $allowe
* This function performs a number of transformations while parsing attribute strings:
* - It normalizes attribute values and surrounds them with double quotes.
* - It normalizes HTML character references inside attribute values.
* - It removes “bad” URL protocols from attribute values.
* - It removes “bad” URL protocols from attribute values, including each URL in a `srcset`.
*
* Otherwise this reads the attributes as if they were part of an HTML tag. It performs
* these transformations to lower the risk of mis-parsing down the line and to perform
Expand All @@ -2767,14 +2813,18 @@ function wp_kses_attr_check( &$name, &$value, &$whole, $vless, $element, $allowe
*
* @since 1.0.0
* @since 7.0.0 Reliably parses HTML via the HTML API.
* @since 7.2.0 Added the optional `$element` parameter.
*
* @param string $attr Attribute list from HTML element to closing HTML element tag.
* @param string[] $allowed_protocols Array of allowed URL protocols.
* @param string $element Optional. Name of the element the attributes belong to, which
* decides how URL attributes such as `srcset` are read. Default ''.
* @return array<string, array{name: string, value: string, whole: string, vless: 'y'|'n'}> Array of attribute information after parsing.
*/
function wp_kses_hair( $attr, $allowed_protocols ) {
function wp_kses_hair( $attr, $allowed_protocols, $element = '' ) {
$attributes = array();
$uris = wp_kses_uri_attributes();
$element = strtolower( (string) $element );

$processor = new WP_HTML_Tag_Processor( "<wp {$attr}>" );
$processor->next_token();
Expand All @@ -2795,8 +2845,8 @@ function wp_kses_hair( $attr, $allowed_protocols ) {
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_url_attribute( $element, $name, $value, $allowed_protocols, $uris );
}

// Reconstruct and normalize the attribute value.
Expand Down Expand Up @@ -3200,6 +3250,97 @@ 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.
*
* 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;
$result = '';

/*
* Candidates are only copied once one has to be removed. Until then the
* scan allocates nothing but the URLs it checks, and an all-allowed value
* is returned as the very string that was passed in.
*
* On the first removal the scan restarts to rebuild the output; candidates
* before `$checked_until` passed their check in the first pass and are not
* checked again.
*/
$rebuild = false;
$checked_until = 0;

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.
if ( strlen( $url ) === $url_length ) {
$at += strcspn( $srcset, ',', $at );
$candidate_length = $at - $candidate_at;
} else {
$candidate_length = strlen( $url );
}

if ( $rebuild && $candidate_at < $checked_until ) {
$is_allowed = true;
} else {
$is_allowed = '/' === $url[0] || 0 === strcasecmp( wp_kses_bad_protocol( $url, $allowed_protocols ), $url );
}

if ( $rebuild ) {
if ( $is_allowed ) {
$result .= ( '' === $result ? '' : ', ' ) . rtrim( substr( $srcset, $candidate_at, $candidate_length ), $whitespace );
}
continue;
}

if ( ! $is_allowed ) {
$rebuild = true;
$checked_until = $candidate_at;
$at = 0;
}
}

return $rebuild ? $result : $srcset;
}

/**
* Converts and fixes HTML entities.
*
Expand Down
136 changes: 136 additions & 0 deletions tests/phpunit/tests/kses.php
Original file line number Diff line number Diff line change
Expand Up @@ -2995,4 +2995,140 @@ public function data_allowed_attributes_in_descriptions() {
),
);
}

/**
* @ticket 29807
*
* @dataProvider data_srcset_is_sanitized_by_element
*
* @param bool $legacy_parser Whether to run the legacy parser instead of the HTML API one.
* @param string $html Input HTML.
* @param string $expected Expected output.
*/
public function test_srcset_is_sanitized_by_element( bool $legacy_parser, string $html, string $expected ) {
if ( $legacy_parser ) {
add_filter( 'wp_kses_force_legacy_parser', '__return_true' );
}

$allowed_html = array(
'img' => array(
'src' => true,
'srcset' => true,
),
'source' => array( 'srcset' => true ),
'link' => array(
'rel' => true,
'imagesrcset' => true,
),
'span' => array( 'srcset' => true ),
);

$this->assertEqualHTML( $expected, wp_kses( $html, $allowed_html ) );
}

/**
* Returns each case once per parser.
*
* @return array<string, array{bool, string, string}>
*/
public function data_srcset_is_sanitized_by_element() {
$cases = array(
'allowed candidates are kept' => array(
'<img src="a.jpg" srcset="a.jpg 1x, https://example.com/b.jpg 2x">',
'<img src="a.jpg" srcset="a.jpg 1x, https://example.com/b.jpg 2x">',
),
'disallowed candidate is removed from img' => array(
'<img src="a.jpg" srcset="a.jpg 1x, javascript:alert(1) 2x">',
'<img src="a.jpg" srcset="a.jpg 1x">',
),
'every candidate disallowed empties srcset' => array(
'<img src="a.jpg" srcset="javascript:alert(1) 1x, vbscript:msgbox(1) 2x">',
'<img src="a.jpg" srcset="">',
),
'character references are decoded first' => array(
'<img src="a.jpg" srcset="a.jpg 1x,&#32;javascript:alert(1) 2x">',
'<img src="a.jpg" srcset="a.jpg 1x">',
),
'source srcset is sanitized' => array(
'<source srcset="javascript:alert(1) 1x, a.webp 2x">',
'<source srcset="a.webp 2x">',
),
'link imagesrcset is sanitized' => array(
'<link rel="preload" imagesrcset="a.jpg 1x, javascript:alert(1) 2x">',
'<link rel="preload" imagesrcset="a.jpg 1x">',
),
// Browsers do not read srcset on other elements, so the value is not parsed as one.
'srcset on another element is left alone' => array(
'<span srcset="javascript:alert(1) 1x">text</span>',
'<span srcset="javascript:alert(1) 1x">text</span>',
),
'src is still sanitized as a single URL' => array(
'<img src="javascript:alert(1)" srcset="a.jpg 1x">',
'<img src="alert(1)" srcset="a.jpg 1x">',
),
);

$data = array();
foreach ( $cases as $name => $case ) {
$data[ "{$name} (HTML API parser)" ] = array( false, ...$case );
$data[ "{$name} (legacy parser)" ] = array( true, ...$case );
}

return $data;
}

/**
* @ticket 29807
*
* @dataProvider data_one_attr_sanitizes_srcset
*
* @param string $element Element the attribute belongs to.
* @param string $input Attribute as written.
* @param string $expected Filtered attribute.
*/
public function test_one_attr_sanitizes_srcset( string $element, string $input, string $expected ) {
add_filter(
'wp_kses_allowed_html',
static function ( $tags ) {
$tags['img']['srcset'] = true;
$tags['span']['srcset'] = true;
return $tags;
}
);

$this->assertSame( $expected, wp_kses_one_attr( $input, $element ) );
}

/**
* @return array<string, array{string, string, string}>
*/
public function data_one_attr_sanitizes_srcset() {
return array(
'allowed candidates are kept' => array(
'img',
'srcset="a.jpg 1x, b.jpg 2x"',
'srcset="a.jpg 1x, b.jpg 2x"',
),
'disallowed candidate is removed' => array(
'img',
'srcset="a.jpg 1x, javascript:alert(1) 2x"',
'srcset="a.jpg 1x"',
),
'character references are decoded first' => array(
'img',
'srcset="a.jpg 1x,&#32;javascript:alert(1) 2x"',
'srcset="a.jpg 1x"',
),
'ampersand in a kept URL stays escaped' => array(
'img',
'srcset="a.jpg?x=1&amp;y=2 1x, javascript:alert(1) 2x"',
'srcset="a.jpg?x=1&amp;y=2 1x"',
),
'srcset on another element is left alone' => array(
'span',
'srcset="javascript:alert(1) 1x"',
'srcset="javascript:alert(1) 1x"',
),
);
}
}
Loading
Loading