diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index c68b71fcc1527..c67d0beda54d8 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -3200,6 +3200,93 @@ 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. + * 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 alter is removed whole, descriptors included. As in {@see esc_url()}, + * a URL starting with `/` cannot carry a scheme and is kept as is. Descriptors are + * not validated. + * + * The remaining candidates are rejoined with `, `, so whitespace around them and + * empty candidates do not survive into the result. + * + * Example: + * + * // A candidate with a disallowed protocol is removed in full. + * 'a.jpg 1x, c.jpg 3x' === wp_kses_sanitize_srcset( 'a.jpg 1x, javascript:alert(1) 2x, c.jpg 3x', $protocols ); + * + * // A comma inside the URL does not end the candidate. + * 'https://cdn.example/w=400,q=80/a.jpg 400w' === wp_kses_sanitize_srcset( 'https://cdn.example/w=400,q=80/a.jpg 400w', $protocols ); + * + * // Separators are normalized. + * 'a.jpg 1x, b.jpg 2x' === wp_kses_sanitize_srcset( " a.jpg 1x ,,\n b.jpg 2x ", $protocols ); + * + * 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. + * + * @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(); + + 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 ); + } + } + + return implode( ', ', $kept ); +} + /** * Converts and fixes HTML entities. * diff --git a/tests/phpunit/tests/kses/wpKsesSanitizeSrcset.php b/tests/phpunit/tests/kses/wpKsesSanitizeSrcset.php new file mode 100644 index 0000000000000..efd1ddbbf1e0b --- /dev/null +++ b/tests/phpunit/tests/kses/wpKsesSanitizeSrcset.php @@ -0,0 +1,193 @@ +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 + */ + public function data_allowed_values_are_returned_unchanged(): array { + return array( + 'empty value' => array( '' ), + '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' ), + 'tab between URL and descriptor' => array( "image1.jpg\t1x, image2.jpg\t2x" ), + 'URL syntax characters are kept as is' => array( '/image-service?yolo=meme&size=large 2x, /image-service?size=small&q="80" 1x' ), + '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' ), + 'descriptors are not sanitized as URLs' => array( 'a.jpg javascript:alert(1), b.jpg 2x' ), + 'whitespace ends the URL before a scheme forms' => array( "a.jpg 1x, java\tscript:alert(1) 2x" ), + ); + } + + /** + * @ticket 29807 + * + * @dataProvider data_separators_are_normalized + * + * @param string $srcset Value with irregular whitespace or commas between candidates. + * @param string $expected Value with the candidates rejoined with `, `. + */ + public function test_separators_are_normalized( string $srcset, string $expected ): void { + $this->assertSame( $expected, wp_kses_sanitize_srcset( $srcset, $this->allowed_protocols ) ); + } + + /** + * @return array + */ + public function data_separators_are_normalized(): array { + return array( + 'whitespace only' => array( " \t\n", '' ), + 'commas only' => array( ',,,,', '' ), + 'commas and whitespace only' => array( ' , , ', '' ), + 'no whitespace after the comma' => array( 'image1.jpg 1x,image2.jpg 2x', 'image1.jpg 1x, image2.jpg 2x' ), + 'surrounding whitespace' => array( ' image1.jpg 1x , image2.jpg 2x ', 'image1.jpg 1x, image2.jpg 2x' ), + 'newline after the comma' => array( "image1.jpg 1x,\nimage2.jpg 2x", 'image1.jpg 1x, image2.jpg 2x' ), + 'empty candidate between two commas' => array( 'image1.jpg 1x,, image2.jpg 2x', 'image1.jpg 1x, image2.jpg 2x' ), + 'empty candidate with whitespace' => array( 'image1.jpg 1x, , image2.jpg 2x', 'image1.jpg 1x, image2.jpg 2x' ), + 'trailing comma' => array( 'image1.jpg 1x, image2.jpg 2x,', 'image1.jpg 1x, image2.jpg 2x' ), + 'leading comma' => array( ', image1.jpg 1x', 'image1.jpg 1x' ), + ); + } + + /** + * @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 + */ + 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, 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', + ), + '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', + ), + 'irregular separators around a removed candidate' => 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() ) ); + } +}