From 8c0adc93df9501da684fef9c53cbc9de8c2ea487 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 29 Sep 2023 19:45:53 +0000 Subject: [PATCH] Script Loader: Harden removal of script tag wrappers. * Add `wp_remove_surrounding_empty_script_tags()` to more precisely remove script tag wrappers and warn when doing it wrong. * Add clarifying comments for XML escaping logic in `wp_get_inline_script_tag()`. * Leverage `WP_HTML_Tag_Processor` in `test_remove_frameless_preview_messenger_channel`. * Reuse `assertEqualMarkup` in `test_blocking_dependent_with_delayed_dependency`. * Normalize whitespace in `parse_markup_fragment` for `assertEqualMarkup`. Follow-up to [56687]. Props dmsnell, westonruter, flixos90. See #58664. git-svn-id: https://develop.svn.wordpress.org/trunk@56748 602fd350-edb4-49c9-b593-d223f7449a82 --- phpcs.xml.dist | 1 + .../class-wp-customize-manager.php | 8 +- src/wp-includes/functions.php | 2 +- src/wp-includes/script-loader.php | 86 ++++++++++++++++++- src/wp-includes/theme-templates.php | 2 +- src/wp-includes/theme.php | 2 +- .../widgets/class-wp-widget-archives.php | 2 +- .../widgets/class-wp-widget-categories.php | 2 +- src/wp-login.php | 10 +-- tests/phpunit/tests/customize/manager.php | 4 +- tests/phpunit/tests/dependencies/scripts.php | 16 +++- .../wpRemoveSurroundingEmptyScriptTags.php | 78 +++++++++++++++++ 12 files changed, 193 insertions(+), 20 deletions(-) create mode 100644 tests/phpunit/tests/dependencies/wpRemoveSurroundingEmptyScriptTags.php diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 8736e29f8c..ab8124af29 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -171,6 +171,7 @@ + diff --git a/src/wp-includes/class-wp-customize-manager.php b/src/wp-includes/class-wp-customize-manager.php index aebcfeb892..c1cf81f142 100644 --- a/src/wp-includes/class-wp-customize-manager.php +++ b/src/wp-includes/class-wp-customize-manager.php @@ -474,7 +474,7 @@ final class WP_Customize_Manager { } )( wp.customize, ); ', '' ), '', ob_get_clean() ) ); + $message .= wp_get_inline_script_tag( wp_remove_surrounding_empty_script_tags( ob_get_clean() ) ); } wp_die( $message ); @@ -2109,7 +2109,7 @@ final class WP_Customize_Manager { } )(); ', '' ), '', ob_get_clean() ) ); + wp_print_inline_script_tag( wp_remove_surrounding_empty_script_tags( ob_get_clean() ) ); } /** @@ -2230,7 +2230,7 @@ final class WP_Customize_Manager { })( _wpCustomizeSettings.values ); ', '' ), '', ob_get_clean() ) ); + wp_print_inline_script_tag( wp_remove_surrounding_empty_script_tags( ob_get_clean() ) ); } /** @@ -5019,7 +5019,7 @@ final class WP_Customize_Manager { ?> ', '' ), '', ob_get_clean() ) ); + wp_print_inline_script_tag( wp_remove_surrounding_empty_script_tags( ob_get_clean() ) ); } /** diff --git a/src/wp-includes/functions.php b/src/wp-includes/functions.php index d7c0a9bbdf..d070c1695a 100644 --- a/src/wp-includes/functions.php +++ b/src/wp-includes/functions.php @@ -7619,7 +7619,7 @@ function wp_post_preview_js() { }()); ', '' ), '', ob_get_clean() ) ); + wp_print_inline_script_tag( wp_remove_surrounding_empty_script_tags( ob_get_clean() ) ); } /** diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index 4f17461752..1527c69819 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -2853,9 +2853,43 @@ function wp_get_inline_script_tag( $javascript, $attributes = array() ) { ); } - // Ensure markup is XHTML compatible if not HTML5. + /* + * XHTML extracts the contents of the SCRIPT element and then the XML parser + * decodes character references and other syntax elements. This can lead to + * misinterpretation of the script contents or invalid XHTML documents. + * + * Wrapping the contents in a CDATA section instructs the XML parser not to + * transform the contents of the SCRIPT element before passing them to the + * JavaScript engine. + * + * Example: + * + * + * + * In an HTML document this would print "…" to the console, + * but in an XHTML document it would print "…" to the console. + * + * + * + * In an HTML document this would print "An image is in HTML", + * but it's an invalid XHTML document because it interprets the `` + * as an empty tag missing its closing `/`. + * + * @see https://www.w3.org/TR/xhtml1/#h-4.8 + */ if ( ! $is_html5 ) { - $javascript = str_replace( ']]>', ']]]]>', $javascript ); // Escape any existing CDATA section. + /* + * If the string `]]>` exists within the JavaScript it would break + * out of any wrapping CDATA section added here, so to start, it's + * necessary to escape that sequence which requires splitting the + * content into two CDATA sections wherever it's found. + * + * Note: it's only necessary to escape the closing `]]>` because + * an additional `', ']]]]>', $javascript ); + + // Wrap the entire escaped script inside a CDATA section. $javascript = sprintf( "/* */", $javascript ); } @@ -3299,3 +3333,51 @@ function wp_add_editor_classic_theme_styles( $editor_settings ) { return $editor_settings; } + +/** + * Removes leading and trailing _empty_ script tags. + * + * This is a helper meant to be used for literal script tag construction + * within `wp_get_inline_script_tag()` or `wp_print_inline_script_tag()`. + * It removes the literal values of "" from + * around an inline script after trimming whitespace. Typlically this + * is used in conjunction with output buffering, where `ob_get_clean()` + * is passed as the `$contents` argument. + * + * Example: + * + * // Strips exact literal empty SCRIPT tags. + * $js = '; + * 'sayHello();' === wp_remove_surrounding_empty_script_tags( $js ); + * + * // Otherwise if anything is different it warns in the JS console. + * $js = ''; + * 'console.error( ... )' === wp_remove_surrounding_empty_script_tags( $js ); + * + * @private + * @since 6.4.0 + * + * @see wp_print_inline_script_tag() + * @see wp_get_inline_script_tag() + * + * @param string $contents Script body with manually created SCRIPT tag literals. + * @return string Script body without surrounding script tag literals, or + * original contents if both exact literals aren't present. + */ +function wp_remove_surrounding_empty_script_tags( $contents ) { + $contents = trim( $contents ); + $opener = ''; + + if ( + strlen( $contents ) > strlen( $opener ) + strlen( $closer ) && + strtoupper( substr( $contents, 0, strlen( $opener ) ) ) === $opener && + strtoupper( substr( $contents, -strlen( $closer ) ) ) === $closer + ) { + return substr( $contents, strlen( $opener ), -strlen( $closer ) ); + } else { + $error_message = __( 'Expected string to start with script tag (without attributes) and end with script tag, with optional whitespace.' ); + _doing_it_wrong( __FUNCTION__, $error_message, '6.4' ); + return sprintf( 'console.error(%s)', wp_json_encode( __( 'Function wp_remove_surrounding_empty_script_tags() used incorrectly in PHP.' ) . ' ' . $error_message ) ); + } +} diff --git a/src/wp-includes/theme-templates.php b/src/wp-includes/theme-templates.php index 8f0ee9a69f..cc4b19ecce 100644 --- a/src/wp-includes/theme-templates.php +++ b/src/wp-includes/theme-templates.php @@ -205,7 +205,7 @@ function the_block_template_skip_link() { }() ); ', '' ), '', ob_get_clean() ); + $skip_link_script = wp_remove_surrounding_empty_script_tags( ob_get_clean() ); $script_handle = 'wp-block-template-skip-link'; wp_register_script( $script_handle, false ); wp_add_inline_script( $script_handle, $skip_link_script ); diff --git a/src/wp-includes/theme.php b/src/wp-includes/theme.php index 087d563352..1eccd460b1 100644 --- a/src/wp-includes/theme.php +++ b/src/wp-includes/theme.php @@ -3800,7 +3800,7 @@ function wp_customize_support_script() { }()); ', '' ), '', ob_get_clean() ) ); + wp_print_inline_script_tag( wp_remove_surrounding_empty_script_tags( ob_get_clean() ) ); } /** diff --git a/src/wp-includes/widgets/class-wp-widget-archives.php b/src/wp-includes/widgets/class-wp-widget-archives.php index 21d3a7b672..7457244833 100644 --- a/src/wp-includes/widgets/class-wp-widget-archives.php +++ b/src/wp-includes/widgets/class-wp-widget-archives.php @@ -120,7 +120,7 @@ class WP_Widget_Archives extends WP_Widget { })(); ', '' ), '', ob_get_clean() ) ); + wp_print_inline_script_tag( wp_remove_surrounding_empty_script_tags( ob_get_clean() ) ); } else { $format = current_theme_supports( 'html5', 'navigation-widgets' ) ? 'html5' : 'xhtml'; diff --git a/src/wp-includes/widgets/class-wp-widget-categories.php b/src/wp-includes/widgets/class-wp-widget-categories.php index e00ce7f960..b79881e752 100644 --- a/src/wp-includes/widgets/class-wp-widget-categories.php +++ b/src/wp-includes/widgets/class-wp-widget-categories.php @@ -108,7 +108,7 @@ class WP_Widget_Categories extends WP_Widget { ', '' ), '', ob_get_clean() ) ); + wp_print_inline_script_tag( wp_remove_surrounding_empty_script_tags( ob_get_clean() ) ); } else { $format = current_theme_supports( 'html5', 'navigation-widgets' ) ? 'html5' : 'xhtml'; diff --git a/src/wp-login.php b/src/wp-login.php index 80887f0420..f03eea3636 100644 --- a/src/wp-login.php +++ b/src/wp-login.php @@ -105,7 +105,7 @@ function login_header( $title = 'Log In', $message = '', $wp_error = null ) { ?> ', '' ), '', ob_get_clean() ) ); + wp_print_inline_script_tag( wp_remove_surrounding_empty_script_tags( ob_get_clean() ) ); } /** @@ -419,12 +419,12 @@ function login_footer( $input_id = '' ) { if ( ! empty( $input_id ) ) { ob_start(); ?> - ', '' ), '', ob_get_clean() ) ); + wp_print_inline_script_tag( wp_remove_surrounding_empty_script_tags( ob_get_clean() ) ); } /** @@ -1362,7 +1362,7 @@ switch ( $action ) { ?> ', '' ), '', ob_get_clean() ) ); + wp_print_inline_script_tag( wp_remove_surrounding_empty_script_tags( ob_get_clean() ) ); } ?> @@ -1627,7 +1627,7 @@ switch ( $action ) { }()); ', '' ), '', ob_get_clean() ) ); + wp_print_inline_script_tag( wp_remove_surrounding_empty_script_tags( ob_get_clean() ) ); } login_footer(); diff --git a/tests/phpunit/tests/customize/manager.php b/tests/phpunit/tests/customize/manager.php index 44f4c52ed9..9f0ff383ed 100644 --- a/tests/phpunit/tests/customize/manager.php +++ b/tests/phpunit/tests/customize/manager.php @@ -3135,8 +3135,8 @@ class Tests_WP_Customize_Manager extends WP_UnitTestCase { $manager = new WP_Customize_Manager( array( 'messenger_channel' => 'preview-0' ) ); ob_start(); $manager->remove_frameless_preview_messenger_channel(); - $output = ob_get_clean(); - $this->assertStringContainsString( 'assertTrue( $processor->next_tag( 'script' ), 'Failed to find expected SCRIPT element in output.' ); } /** diff --git a/tests/phpunit/tests/dependencies/scripts.php b/tests/phpunit/tests/dependencies/scripts.php index e785812b4a..7f2b956127 100644 --- a/tests/phpunit/tests/dependencies/scripts.php +++ b/tests/phpunit/tests/dependencies/scripts.php @@ -260,8 +260,11 @@ JS; wp_enqueue_script( 'main-script-a3', '/main-script-a3.js', array(), null, compact( 'strategy' ) ); wp_enqueue_script( 'dependent-script-a3', '/dependent-script-a3.js', array( 'main-script-a3' ), null ); $output = get_echo( 'wp_print_scripts' ); - $expected = str_replace( "'", '"', "" ); - $this->assertStringContainsString( $expected, $output, 'Blocking dependents must force delayed dependencies to become blocking.' ); + $expected = << + +JS; + $this->assertEqualMarkup( $expected, $output, 'Blocking dependents must force delayed dependencies to become blocking.' ); } /** @@ -2997,6 +3000,15 @@ HTML } } + // Normalize other whitespace nodes. + $xpath = new DOMXPath( $dom ); + foreach ( $xpath->query( '//text()' ) as $node ) { + /** @var DOMText $node */ + if ( preg_match( '/^\s+$/', $node->nodeValue ) ) { + $node->nodeValue = ' '; + } + } + return $dom; } diff --git a/tests/phpunit/tests/dependencies/wpRemoveSurroundingEmptyScriptTags.php b/tests/phpunit/tests/dependencies/wpRemoveSurroundingEmptyScriptTags.php new file mode 100644 index 0000000000..39b0480ccb --- /dev/null +++ b/tests/phpunit/tests/dependencies/wpRemoveSurroundingEmptyScriptTags.php @@ -0,0 +1,78 @@ + array( + '', + 'alert("hello")', + false, + ), + 'BASIC_CASE' => array( + '', + 'alert("hello")', + false, + ), + 'whitespace_basic_case' => array( + ' ', + 'alert("hello")', + false, + ), + 'missing_tags' => array( + 'alert("hello")', + $error_js, + true, + ), + 'missing_start_tag' => array( + 'alert("hello")', + $error_js, + true, + ), + 'missing_end_tag' => array( + '', + $error_js, + true, + ), + ); + } + + /** + * Test scenarios for wp_remove_surrounding_empty_script_tags(). + * + * @dataProvider get_data_to_test_wp_remove_surrounding_empty_script_tags + * + * @param string $input Input. + * @param string $expected Expected. + * @param bool $expect_doing_it_wrong Whether input is _doing_it_wrong(). + */ + public function test_wp_remove_surrounding_empty_script_tags( $input, $expected, $expect_doing_it_wrong ) { + if ( $expect_doing_it_wrong ) { + $this->setExpectedIncorrectUsage( 'wp_remove_surrounding_empty_script_tags' ); + } + + $this->assertSame( + $expected, + wp_remove_surrounding_empty_script_tags( $input ) + ); + } +}