Skip to content

Fix inline script tag escaping #14

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 18 commits into
base: trunk
Choose a base branch
from
Draft
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
2 changes: 1 addition & 1 deletion src/wp-includes/block-editor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'
);
Expand Down
48 changes: 36 additions & 12 deletions src/wp-includes/functions.wp-scripts.php
Original file line number Diff line number Diff line change
Expand Up @@ -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, '</script>' ) ) {
_doing_it_wrong(
__FUNCTION__,
sprintf(
/* translators: 1: <script>, 2: wp_add_inline_script() */
__( 'Do not pass %1$s tags to %2$s.' ),
'<code>&lt;script&gt;</code>',
'<code>wp_add_inline_script()</code>'
),
'4.5.0'
);
$data = trim( preg_replace( '#<script[^>]*>(.*)</script>#is', '$1', $data ) );
/*
* Check whether the script data appears to be enclosed in an HTML <script> tag.
*/
if (
strlen( $data ) >= 17 &&
0 === substr_compare( $data, '<script', 0, 7, true ) &&
(
"\t" === $data[7] ||
"\n" === $data[7] ||
/*
* \r\n and \r are normalized to \n in HTML newline normalization.
* Therefore, \r always behaves like \n and terminates a tag name.
*/
"\r" === $data[7] ||
"\f" === $data[7] ||
' ' === $data[7] ||
'/' === $data[7] ||
'>' === $data[7]
)
) {
// Try to parse and extract the script contents.
$processor = new WP_HTML_Tag_Processor( $data );
$processor->next_token();
if ( $processor->get_tag() === 'SCRIPT' ) {
_doing_it_wrong(
__FUNCTION__,
sprintf(
/* translators: 1: <script>, 2: wp_add_inline_script() */
__( 'Do not pass %1$s tags to %2$s.' ),
'<code>&lt;script&gt;</code>',
'<code>wp_add_inline_script()</code>'
),
'4.5.0'
);
$data = $processor->get_modifiable_text();
}
}

return wp_scripts()->add_inline_script( $handle, $data, $position );
Expand Down
172 changes: 163 additions & 9 deletions src/wp-includes/html-api/class-wp-html-tag-processor.php
Original file line number Diff line number Diff line change
Expand Up @@ -3726,17 +3726,54 @@ public function set_modifiable_text( string $plaintext_content ): bool {
switch ( $this->get_tag() ) {
case 'SCRIPT':
/*
* This is over-protective, but ensures the update doesn't break
* out of the SCRIPT element. A more thorough check would need to
* ensure that the script closing tag doesn't exist, and isn't
* also "hidden" inside the script double-escaped state.
* SCRIPT tag contents can be dangerous.
*
* It may seem like replacing `</script` with `<\/script` would
* properly escape these things, but this could mask regex patterns
* that previously worked. Resolve this by not sending `</script`
* The text `</script>` could close the SCRIPT element prematurely.
*
* The text `<script>` could enter the "script data double escaped state", preventing the
* SCRIPT element from closing as expected, for example:
*
* <script>
* // If this "<!--" then "<script>" the closing tag will not be recognized.
* </script>
* <h1>This appears inside the preceding SCRIPT element.</h1>
*
* 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, '</script' ) ) {
return false;
if ( preg_match( '~</?script[\t\r\n\f />]~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.
Copy link
Preview

Copilot AI Aug 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This TODO comment suggests unfinished work. Consider either implementing the suggested feature for JSON and importmap script tags or removing this comment if it's not planned for this release.

Suggested change
* @todo consider applying to JSON and importmap script tags as well.

Copilot uses AI. Check for mistakes.

*/
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(
Expand Down Expand Up @@ -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}
Copy link
Preview

Copilot AI Aug 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The @SInCE tag contains a placeholder '{WP_VERSION}' instead of an actual version number. This should be replaced with the specific WordPress version when this feature is released.

Suggested change
* @since {WP_VERSION}
* @since 6.5.0

Copilot uses AI. Check for mistakes.

*
* @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.
*
Expand Down
6 changes: 6 additions & 0 deletions src/wp-includes/script-loader.php
Original file line number Diff line number Diff line change
Expand Up @@ -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( '<script%s></script>', 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( "<script%s>%s</script>\n", wp_sanitize_script_attributes( $attributes ), $data );
}

Expand Down
62 changes: 54 additions & 8 deletions tests/phpunit/tests/blocks/editor.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
* @group blocks
*/
class Tests_Blocks_Editor extends WP_UnitTestCase {

/**
* Sets up each test method.
*/
Expand Down Expand Up @@ -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 );
}

/**
Expand Down Expand Up @@ -697,28 +696,75 @@ 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' ),
'expected' => '/?context=edit',
),
'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 `<script>` from closing on its apparent closer and remain open.
*/
public function test_ensure_preload_data_script_tag_closes() {
add_theme_support( 'html5', array( 'script' ) );
register_rest_route(
'test/v0',
'test-62797',
array(
'methods' => 'GET',
'callback' => function () {
return '<!-- unclosed comment and a script tag <SCRIPT></SCRIPT><script></script>';
},
'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
<script src="{$baseurl}/wp-includes/js/dist/api-fetch.min.js?ver=test" id="wp-api-fetch-js"></script>
<script id="wp-api-fetch-js-after">
wp.apiFetch.use( wp.apiFetch.createPreloadingMiddleware( {"/test/v0/test-62797":{"body":["<!-- unclosed comment and a script tag <\u0053CRIPT></\u0053CRIPT><\u0073cript></\u0073cript>"],"headers":{"Allow":"GET"}}} ) );
</script>

HTML;
$this->assertEqualHTML( $expected, $output );
}
}
Loading
Loading