diff --git a/plugins/embed-optimizer/class-embed-optimizer-tag-visitor.php b/plugins/embed-optimizer/class-embed-optimizer-tag-visitor.php index f338df5dce..ae48d6455f 100644 --- a/plugins/embed-optimizer/class-embed-optimizer-tag-visitor.php +++ b/plugins/embed-optimizer/class-embed-optimizer-tag-visitor.php @@ -187,7 +187,7 @@ private function reduce_layout_shifts( OD_Tag_Visitor_Context $context ): void { foreach ( $minimums as $minimum ) { $style_rule = sprintf( '#%s { min-height: %dpx; }', - $element_id, + $this->escape_css( $element_id ), $minimum['height'] ); @@ -206,6 +206,79 @@ private function reduce_layout_shifts( OD_Tag_Visitor_Context $context ): void { } } + /** + * Escapes a CSS identifier. + * + * This is a PHP implementation of the CSS.escape() method in CSSOM, based on the + * JavaScript polyfill by Mathias Bynens. + * + * @since 1.0.0 + * @link https://drafts.csswg.org/cssom/#the-css.escape()-method + * @link https://github.com/mathiasbynens/CSS.escape + * @link https://mathiasbynens.be/notes/css-escapes + * @license MIT + * + * @param string $ident Identifier to escape. + * @return string Escaped identifier. + */ + private function escape_css( string $ident ): string { + $length = strlen( $ident ); + $result = ''; + $first_code_unit = $length > 0 ? ord( $ident[0] ) : 0; + + for ( $i = 0; $i < $length; $i++ ) { + $code_unit = ord( $ident[ $i ] ); + + // If the character is NULL (U+0000), then the REPLACEMENT CHARACTER (U+FFFD). + if ( 0x0000 === $code_unit ) { + $result .= "\u{FFFD}"; + continue; + } + + if ( + // If the character is in the range [\1-\1f] (U+0001 to U+001F) or is U+007F... + $code_unit <= 0x001F || 0x007F === $code_unit || + // If the character is the first character and is in the range [0-9] (U+0030 to U+0039)... + ( 0 === $i && $code_unit >= 0x0030 && $code_unit <= 0x0039 ) || + // If the character is the second character and is in the range [0-9] (U+0030 to U+0039) and the first character is a `-` (U+002D)... + ( 1 === $i && $code_unit >= 0x0030 && $code_unit <= 0x0039 && 0x002D === $first_code_unit ) + ) { + $result .= '\\' . dechex( $code_unit ) . ' '; + continue; + } + + // If the character is the first character and is a `-` (U+002D), and there is no second character... + if ( + 0 === $i && + 1 === $length && + 0x002D === $code_unit + ) { + $result .= '\\' . $ident[ $i ]; + continue; + } + + // If the character is not handled by one of the above rules and is + // greater than or equal to U+0080, is `-` (U+002D) or `_` (U+005F), or + // is in one of the ranges [0-9] (U+0030 to U+0039), [A-Z] (U+0041 to + // U+005A), or [a-z] (U+0061 to U+007A)... + if ( + $code_unit >= 0x0080 || + 0x002D === $code_unit || + 0x005F === $code_unit || + ( $code_unit >= 0x0030 && $code_unit <= 0x0039 ) || + ( $code_unit >= 0x0041 && $code_unit <= 0x005A ) || + ( $code_unit >= 0x0061 && $code_unit <= 0x007A ) + ) { + $result .= $ident[ $i ]; + continue; + } + + // Otherwise, the escaped character. + $result .= '\\' . $ident[ $i ]; + } + return $result; + } + /** * Gets preconnect URLs based on embed type. * diff --git a/plugins/embed-optimizer/load.php b/plugins/embed-optimizer/load.php index 5cb973ba1d..e0b5de07af 100644 --- a/plugins/embed-optimizer/load.php +++ b/plugins/embed-optimizer/load.php @@ -5,7 +5,7 @@ * Description: Optimizes the performance of embeds through lazy-loading, preconnecting, and reserving space to reduce layout shifts. * Requires at least: 6.6 * Requires PHP: 7.2 - * Version: 1.0.0-beta3 + * Version: 1.0.0-beta4 * Author: WordPress Performance Team * Author URI: https://make.wordpress.org/performance/ * License: GPLv2 or later @@ -71,7 +71,7 @@ static function ( string $global_var_name, string $version, Closure $load ): voi } )( 'embed_optimizer_pending_plugin', - '1.0.0-beta3', + '1.0.0-beta4', static function ( string $version ): void { if ( defined( 'EMBED_OPTIMIZER_VERSION' ) ) { return; diff --git a/plugins/embed-optimizer/readme.txt b/plugins/embed-optimizer/readme.txt index 95d367fdc0..0ed3c4e74a 100644 --- a/plugins/embed-optimizer/readme.txt +++ b/plugins/embed-optimizer/readme.txt @@ -2,7 +2,7 @@ Contributors: wordpressdotorg Tested up to: 6.9 -Stable tag: 1.0.0-beta3 +Stable tag: 1.0.0-beta4 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html Tags: performance, embeds, optimization-detective @@ -67,6 +67,12 @@ The [plugin source code](https://github.com/WordPress/performance/tree/trunk/plu == Changelog == += 1.0.0-beta4 = + +**Security** + +* Add escaping for ID selector in styles added to reduce layout shifts. This fixes an XSS security vulnerability which required an authenticated user with at least a contributor role. Props to [duc193](https://github.com/nduc193) for [responsible disclosure](https://github.com/WordPress/performance/blob/trunk/SECURITY.md). ([2397](https://github.com/WordPress/performance/pull/2397)) + = 1.0.0-beta3 = **Enhancements** diff --git a/plugins/embed-optimizer/tests/test-cases/figures-with-fancy-ids/buffer.html b/plugins/embed-optimizer/tests/test-cases/figures-with-fancy-ids/buffer.html new file mode 100644 index 0000000000..3275256bf8 --- /dev/null +++ b/plugins/embed-optimizer/tests/test-cases/figures-with-fancy-ids/buffer.html @@ -0,0 +1,44 @@ + + + + ... + + + + +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ + diff --git a/plugins/embed-optimizer/tests/test-cases/figures-with-fancy-ids/expected.html b/plugins/embed-optimizer/tests/test-cases/figures-with-fancy-ids/expected.html new file mode 100644 index 0000000000..2873f2eee2 --- /dev/null +++ b/plugins/embed-optimizer/tests/test-cases/figures-with-fancy-ids/expected.html @@ -0,0 +1,83 @@ + + + + ... + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ + diff --git a/plugins/embed-optimizer/tests/test-cases/figures-with-fancy-ids/set-up.php b/plugins/embed-optimizer/tests/test-cases/figures-with-fancy-ids/set-up.php new file mode 100644 index 0000000000..c80e8af05f --- /dev/null +++ b/plugins/embed-optimizer/tests/test-cases/figures-with-fancy-ids/set-up.php @@ -0,0 +1,25 @@ +populate_url_metrics( + array( + array( + 'xpath' => '/HTML/BODY/DIV[@class=\'wp-site-blocks\']/*[1][self::FIGURE]/*[1][self::DIV]', + 'isLCP' => true, + 'intersectionRatio' => 1, + 'resizedBoundingClientRect' => array_merge( $test_case->get_sample_dom_rect(), array( 'height' => 500 ) ), + ), + array( + 'xpath' => '/HTML/BODY/DIV[@class=\'wp-site-blocks\']/*[2][self::FIGURE]/*[1][self::DIV]', + 'isLCP' => true, + 'intersectionRatio' => 1, + 'resizedBoundingClientRect' => array_merge( $test_case->get_sample_dom_rect(), array( 'height' => 500 ) ), + ), + array( + 'xpath' => '/HTML/BODY/DIV[@class=\'wp-site-blocks\']/*[3][self::FIGURE]/*[1][self::DIV]', + 'isLCP' => true, + 'intersectionRatio' => 1, + 'resizedBoundingClientRect' => array_merge( $test_case->get_sample_dom_rect(), array( 'height' => 500 ) ), + ), + ) + ); +}; diff --git a/plugins/embed-optimizer/tests/test-class-embed-optimizer-tag-visitor-escape-css.php b/plugins/embed-optimizer/tests/test-class-embed-optimizer-tag-visitor-escape-css.php new file mode 100644 index 0000000000..d1352aa1a8 --- /dev/null +++ b/plugins/embed-optimizer/tests/test-class-embed-optimizer-tag-visitor-escape-css.php @@ -0,0 +1,66 @@ +setAccessible( true ); + + $this->assertSame( $expected, $method->invoke( $visitor, $ident ) ); + } + + /** + * Data provider for test_escape_css. + * + * @return array Test cases. + */ + public function data_escape_css(): array { + return array( + 'empty' => array( '', '' ), + 'simple' => array( 'foo', 'foo' ), + 'brackets' => array( 'foo[bar][baz]', 'foo\[bar\]\[baz\]' ), + 'hyphen' => array( '-', '\-' ), + 'unicode' => array( '🌈', '🌈' ), + 'double-hyphen' => array( '--', '--' ), + 'hyphen-digit' => array( '-1', '-\31 ' ), + 'digit' => array( '1', '\31 ' ), + 'digit-alpha' => array( '1a', '\31 a' ), + 'dot' => array( '.foo', '\.foo' ), + 'hash' => array( '#bar', '\#bar' ), + 'space' => array( ' ', '\ ' ), + 'null' => array( "\0", "\xEF\xBF\xBD" ), // U+FFFD is EF BF BD in UTF-8. + 'control' => array( "\x1F", '\1f ' ), + 'delete' => array( "\x7F", '\7f ' ), + 'backslash' => array( '\\', '\\\\' ), + 'underscore' => array( '_', '_' ), + 'mixed' => array( 'foo-bar_baz', 'foo-bar_baz' ), + 'unicode-multibyte' => array( 'é', 'é' ), + 'complex' => array( '1.2#3-4_5', '\31 \.2\#3-4_5' ), + ); + } +}