diff --git a/src/wp-includes/block-editor.php b/src/wp-includes/block-editor.php
index 2bd9f2dfbf050..96fc9c4eb6149 100644
--- a/src/wp-includes/block-editor.php
+++ b/src/wp-includes/block-editor.php
@@ -766,7 +766,7 @@ function block_editor_rest_api_preload( array $preload_paths, $block_editor_cont
'wp-api-fetch',
sprintf(
'wp.apiFetch.use( wp.apiFetch.createPreloadingMiddleware( %s ) );',
- wp_json_encode( $preload_data )
+ wp_json_encode( $preload_data, JSON_UNESCAPED_SLASHES )
),
'after'
);
diff --git a/src/wp-includes/functions.wp-scripts.php b/src/wp-includes/functions.wp-scripts.php
index 1be1822aa7c3d..fced2ebaf9978 100644
--- a/src/wp-includes/functions.wp-scripts.php
+++ b/src/wp-includes/functions.wp-scripts.php
@@ -130,18 +130,42 @@ function wp_print_scripts( $handles = false ) {
function wp_add_inline_script( $handle, $data, $position = 'after' ) {
_wp_scripts_maybe_doing_it_wrong( __FUNCTION__, $handle );
- if ( false !== stripos( $data, '' ) ) {
- _doing_it_wrong(
- __FUNCTION__,
- sprintf(
- /* translators: 1: #is', '$1', $data ) );
+ /*
+ * Check whether the script data appears to be enclosed in an HTML ` could close the SCRIPT element prematurely.
+ *
+ * The text `
+ *
This appears inside the preceding SCRIPT element.
+ *
+ * The relevant state transitions happen on text like:
+ * 1. <
+ * 2. / (optional)
+ * 3. script (case-insensitive)
+ * 4. One of the following characters:
+ * - \t
+ * - \n
+ * - \r (\r and \r\n newlines are normalized to \n in HTML pre-processing)
+ * - \f
+ * - " " (U+0020 SPACE)
+ * - /
+ * - >
+ *
+ * @see https://html.spec.whatwg.org/multipage/parsing.html#script-data-double-escaped-state
*/
- if ( false !== stripos( $plaintext_content, ']~i', $plaintext_content ) ) {
+ /*
+ * JavaScript can be safely escaped.
+ * Non-JavaScript script tags have unknown semantics.
+ *
+ * @todo consider applying to JSON and importmap script tags as well.
+ */
+ if ( $this->is_javascript_script_tag() ) {
+ $plaintext_content = preg_replace_callback(
+ '~<(/?)(s)(cript)([\t\r\n\f />])~i',
+ static function ( $matches ) {
+ $escaped_s_char = 's' === $matches[2]
+ ? '\u0073'
+ : '\u0053';
+ return "<{$matches[1]}{$escaped_s_char}{$matches[3]}{$matches[4]}";
+ },
+ $plaintext_content
+ );
+ } else {
+ return false;
+ }
}
$this->lexical_updates['modifiable text'] = new WP_HTML_Text_Replacement(
@@ -3794,6 +3831,123 @@ static function ( $tag_match ) {
return false;
}
+ /**
+ * Indicates if the currently matched tag is a JavaScript script tag.
+ *
+ * @see https://html.spec.whatwg.org/multipage/scripting.html#prepare-the-script-element
+ *
+ * @since {WP_VERSION}
+ *
+ * @return bool True if the script tag will be evaluated as JavaScript.
+ */
+ public function is_javascript_script_tag(): bool {
+ if ( 'SCRIPT' !== $this->get_tag() || $this->get_namespace() !== 'html' ) {
+ return false;
+ }
+
+ /*
+ * > If any of the following are true:
+ * > - el has a type attribute whose value is the empty string;
+ * > - el has no type attribute but it has a language attribute and that attribute's
+ * > value is the empty string; or
+ * > - el has neither a type attribute nor a language attribute,
+ * > then let the script block's type string for this script element be "text/javascript".
+ */
+ $type_attr = $this->get_attribute( 'type' );
+ $language_attr = $this->get_attribute( 'language' );
+
+ if ( true === $type_attr || '' === $type_attr ) {
+ return true;
+ }
+ if (
+ null === $type_attr && (
+ true === $language_attr ||
+ '' === $language_attr ||
+ null === $language_attr
+ )
+ ) {
+ return true;
+ }
+
+ /*
+ * > Otherwise, if el has a type attribute, then let the script block's type string be
+ * > the value of that attribute with leading and trailing ASCII whitespace stripped.
+ * > Otherwise, el has a non-empty language attribute; let the script block's type string
+ * > be the concatenation of "text/" and the value of el's language attribute.
+ */
+ $type_string = $type_attr ? trim( $type_attr, " \t\f\r\n" ) : "text/{$language_attr}";
+
+ /*
+ * > If the script block's type string is a JavaScript MIME type essence match, then
+ * > set el's type to "classic".
+ *
+ * > A string is a JavaScript MIME type essence match if it is an ASCII case-insensitive
+ * > match for one of the JavaScript MIME type essence strings.
+ *
+ * > A JavaScript MIME type is any MIME type whose essence is one of the following:
+ * >
+ * > - application/ecmascript
+ * > - application/javascript
+ * > - application/x-ecmascript
+ * > - application/x-javascript
+ * > - text/ecmascript
+ * > - text/javascript
+ * > - text/javascript1.0
+ * > - text/javascript1.1
+ * > - text/javascript1.2
+ * > - text/javascript1.3
+ * > - text/javascript1.4
+ * > - text/javascript1.5
+ * > - text/jscript
+ * > - text/livescript
+ * > - text/x-ecmascript
+ * > - text/x-javascript
+ *
+ * @see https://mimesniff.spec.whatwg.org/#javascript-mime-type-essence-match
+ * @see https://mimesniff.spec.whatwg.org/#javascript-mime-type
+ */
+ switch ( strtolower( $type_string ) ) {
+ case 'application/ecmascript':
+ case 'application/javascript':
+ case 'application/x-ecmascript':
+ case 'application/x-javascript':
+ case 'text/ecmascript':
+ case 'text/javascript':
+ case 'text/javascript1.0':
+ case 'text/javascript1.1':
+ case 'text/javascript1.2':
+ case 'text/javascript1.3':
+ case 'text/javascript1.4':
+ case 'text/javascript1.5':
+ case 'text/jscript':
+ case 'text/livescript':
+ case 'text/x-ecmascript':
+ case 'text/x-javascript':
+ return true;
+
+ /*
+ * > Otherwise, if the script block's type string is an ASCII case-insensitive match for
+ * > the string "module", then set el's type to "module".
+ *
+ * A module is evaluated as JavaScript.
+ */
+ case 'module':
+ return true;
+ }
+
+ /*
+ * > - Otherwise, if the script block's type string is an ASCII case-insensitive match for
+ * > the string "importmap", then set el's type to "importmap".
+ *
+ * An importmap is JSON and not evaluated as JavaScript. This case is not handled here.
+ */
+
+ /*
+ * > Otherwise, return. (No script is executed, and el's type is left as null.)
+ */
+ return false;
+ }
+
/**
* Updates or creates a new attribute on the currently matched tag with the passed value.
*
diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php
index 54193c841c7c3..52578b880062d 100644
--- a/src/wp-includes/script-loader.php
+++ b/src/wp-includes/script-loader.php
@@ -3006,6 +3006,12 @@ function wp_get_inline_script_tag( $data, $attributes = array() ) {
*/
$attributes = apply_filters( 'wp_inline_script_attributes', $attributes, $data );
+ $script_tag = sprintf( '', wp_sanitize_script_attributes( $attributes ) );
+ $processor = new WP_HTML_Tag_Processor( $script_tag );
+ if ( $processor->next_tag( 'SCRIPT' ) && $processor->set_modifiable_text( $data ) ) {
+ return $processor->get_updated_html() . "\n";
+ }
+
return sprintf( "\n", wp_sanitize_script_attributes( $attributes ), $data );
}
diff --git a/tests/phpunit/tests/blocks/editor.php b/tests/phpunit/tests/blocks/editor.php
index 838137fa76f0d..71d28e62a4e6f 100644
--- a/tests/phpunit/tests/blocks/editor.php
+++ b/tests/phpunit/tests/blocks/editor.php
@@ -9,7 +9,6 @@
* @group blocks
*/
class Tests_Blocks_Editor extends WP_UnitTestCase {
-
/**
* Sets up each test method.
*/
@@ -631,8 +630,8 @@ function filter_add_preload_paths( $preload_paths, WP_Block_Editor_Context $cont
$after = implode( '', wp_scripts()->registered['wp-api-fetch']->extra['after'] );
$this->assertStringContainsString( 'wp.apiFetch.createPreloadingMiddleware', $after );
- $this->assertStringContainsString( '"\/wp\/v2\/blocks"', $after );
- $this->assertStringContainsString( '"\/wp\/v2\/types"', $after );
+ $this->assertStringContainsString( '"/wp/v2/blocks"', $after );
+ $this->assertStringContainsString( '"/wp/v2/types"', $after );
}
/**
@@ -697,11 +696,11 @@ public function data_block_editor_rest_api_preload_adds_missing_leading_slash()
return array(
'a string without a slash' => array(
'preload_paths' => array( 'wp/v2/blocks' ),
- 'expected' => '\/wp\/v2\/blocks',
+ 'expected' => '/wp/v2/blocks',
),
'a string with a slash' => array(
'preload_paths' => array( '/wp/v2/blocks' ),
- 'expected' => '\/wp\/v2\/blocks',
+ 'expected' => '/wp/v2/blocks',
),
'a string starting with a question mark' => array(
'preload_paths' => array( '?context=edit' ),
@@ -709,16 +708,63 @@ public function data_block_editor_rest_api_preload_adds_missing_leading_slash()
),
'an array with a string without a slash' => array(
'preload_paths' => array( array( 'wp/v2/blocks', 'OPTIONS' ) ),
- 'expected' => '\/wp\/v2\/blocks',
+ 'expected' => '/wp/v2/blocks',
),
'an array with a string with a slash' => array(
'preload_paths' => array( array( '/wp/v2/blocks', 'OPTIONS' ) ),
- 'expected' => '\/wp\/v2\/blocks',
+ 'expected' => '/wp/v2/blocks',
),
'an array with a string starting with a question mark' => array(
'preload_paths' => array( array( '?context=edit', 'OPTIONS' ) ),
- 'expected' => '\/?context=edit',
+ 'expected' => '/?context=edit',
),
);
}
+
+ /**
+ * @ticket 62797
+ *
+ * @covers ::block_editor_rest_api_preload
+ *
+ * Some valid JSON-encoded data is dangerous to embed in HTML without appropriate
+ * escaping. This test includes prints an example of such data that would prevent
+ * the enclosing `';
+ },
+ 'permission_callback' => '__return_true',
+ )
+ );
+
+ // Prevent a bunch of noisy or unstable data from being included in the test output.
+ wp_scripts()->registered['wp-api-fetch']->ver = 'test';
+ wp_scripts()->registered['wp-api-fetch']->extra['after'] = array();
+
+ block_editor_rest_api_preload(
+ array( '/test/v0/test-62797' ),
+ new WP_Block_Editor_Context()
+ );
+
+ ob_start();
+ wp_scripts()->do_item( 'wp-api-fetch' );
+ $output = ob_get_clean();
+
+ $baseurl = site_url();
+ $expected = <<
+
+
+HTML;
+ $this->assertEqualHTML( $expected, $output );
+ }
}
diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php
index 5be6bb8f73083..1830bb15711b2 100644
--- a/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php
+++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessorModifiableText.php
@@ -448,13 +448,14 @@ public static function data_tokens_with_basic_modifiable_text_updates() {
* the structure of the containing element, such as in a script or comment.
*
* @ticket 61617
+ * @ticket 62797
*
* @dataProvider data_unallowed_modifiable_text_updates
*
* @param string $html_with_nonempty_modifiable_text Will be used to find the test element.
* @param string $invalid_update Update containing possibly-compromising text.
*/
- public function test_rejects_updates_with_unallowed_substrings( string $html_with_nonempty_modifiable_text, string $invalid_update ) {
+ public function test_rejects_dangerous_updates( string $html_with_nonempty_modifiable_text, string $invalid_update ) {
$processor = new WP_HTML_Tag_Processor( $html_with_nonempty_modifiable_text );
while ( '' === $processor->get_modifiable_text() && $processor->next_token() ) {
@@ -486,10 +487,56 @@ public function test_rejects_updates_with_unallowed_substrings( string $html_wit
*/
public static function data_unallowed_modifiable_text_updates() {
return array(
- 'Comment with -->' => array( '', 'Comments end in -->' ),
- 'Comment with --!>' => array( '', 'Invalid but legitimate comments end in --!>' ),
- 'SCRIPT with ' => array( '', 'Just a ' ),
- 'SCRIPT with ' => array( '', 'beforeafter' ),
+ 'Comment with -->' => array( '', 'Comments end in -->' ),
+ 'Comment with --!>' => array( '', 'Invalid but legitimate comments end in --!>' ),
+ 'Non-JS SCRIPT with ', '