Commit e8a6e1f192 for wordpress.org

commit e8a6e1f192e560f92eeef984a60e6adb2782bd71
Author: jonsurrell <jonsurrell@git.wordpress.org>
Date:   Thu Jan 15 12:09:51 2026 +0000

    Customize: Allow arbitrary CSS in global styles custom CSS.

    Relax Global Styles custom CSS filters to allow arbitrary CSS.

    Escape HTML characters `<>&` in Global Styles data to prevent it from being mangled by post content filters. The data is JSON encoded and stored in `post_content`. Filters operating on `post_content` expect it to contain HTML. Some KSES filters would otherwise remove essential CSS features like the `<custom-ident>` CSS data type because they appear to be HTML tags.

    [61418] changed STYLE tag generation to use the HTML API for improved safety.

    Developed in https://github.com/WordPress/wordpress-develop/pull/10641.

    Props jonsurrell, dmsnell, westonruter, ramonopoly, oandregal, jorgefilipecosta, sabernhardt, soyebsalar01.
    See #64418.

    Built from https://develop.svn.wordpress.org/trunk@61486


    git-svn-id: http://core.svn.wordpress.org/trunk@60798 1a063a9b-81f0-0310-95a4-ce76da25c4cd

diff --git a/wp-includes/css/dist/index.php b/wp-includes/css/dist/index.php
index e0020d536c..7e5f0fa55c 100644
--- a/wp-includes/css/dist/index.php
+++ b/wp-includes/css/dist/index.php
@@ -7,6 +7,11 @@
  */

 return array(
+	array(
+		'handle' => 'wp-preferences',
+		'path' => 'preferences/style',
+		'dependencies' => array('wp-components'),
+	),
 	array(
 		'handle' => 'wp-nux',
 		'path' => 'nux/style',
@@ -17,11 +22,6 @@ return array(
 		'path' => 'list-reusable-blocks/style',
 		'dependencies' => array('wp-components'),
 	),
-	array(
-		'handle' => 'wp-preferences',
-		'path' => 'preferences/style',
-		'dependencies' => array('wp-components'),
-	),
 	array(
 		'handle' => 'wp-reusable-blocks',
 		'path' => 'reusable-blocks/style',
@@ -57,36 +57,36 @@ return array(
 		'path' => 'block-directory/style',
 		'dependencies' => array('wp-block-editor', 'wp-components', 'wp-editor'),
 	),
-	array(
-		'handle' => 'wp-media-utils',
-		'path' => 'media-utils/style',
-		'dependencies' => array('wp-components'),
-	),
 	array(
 		'handle' => 'wp-customize-widgets',
 		'path' => 'customize-widgets/style',
 		'dependencies' => array('wp-block-editor', 'wp-block-library', 'wp-components', 'wp-media-utils', 'wp-preferences', 'wp-widgets'),
 	),
-	array(
-		'handle' => 'wp-edit-widgets',
-		'path' => 'edit-widgets/style',
-		'dependencies' => array('wp-block-editor', 'wp-block-library', 'wp-components', 'wp-media-utils', 'wp-patterns', 'wp-preferences', 'wp-widgets'),
-	),
 	array(
 		'handle' => 'wp-edit-post',
 		'path' => 'edit-post/style',
 		'dependencies' => array('wp-block-editor', 'wp-block-library', 'wp-commands', 'wp-components', 'wp-editor', 'wp-preferences', 'wp-widgets'),
 	),
 	array(
-		'handle' => 'wp-block-library',
-		'path' => 'block-library/style',
-		'dependencies' => array('wp-block-editor', 'wp-components', 'wp-patterns'),
+		'handle' => 'wp-edit-widgets',
+		'path' => 'edit-widgets/style',
+		'dependencies' => array('wp-block-editor', 'wp-block-library', 'wp-components', 'wp-media-utils', 'wp-patterns', 'wp-preferences', 'wp-widgets'),
+	),
+	array(
+		'handle' => 'wp-media-utils',
+		'path' => 'media-utils/style',
+		'dependencies' => array('wp-components'),
 	),
 	array(
 		'handle' => 'wp-editor',
 		'path' => 'editor/style',
 		'dependencies' => array('wp-block-editor', 'wp-commands', 'wp-components', 'wp-media-utils', 'wp-patterns', 'wp-preferences'),
 	),
+	array(
+		'handle' => 'wp-block-library',
+		'path' => 'block-library/style',
+		'dependencies' => array('wp-block-editor', 'wp-components', 'wp-patterns'),
+	),
 	array(
 		'handle' => 'wp-block-editor',
 		'path' => 'block-editor/style',
diff --git a/wp-includes/js/dist/script-modules/index.php b/wp-includes/js/dist/script-modules/index.php
index 98bb486813..9438e8294f 100644
--- a/wp-includes/js/dist/script-modules/index.php
+++ b/wp-includes/js/dist/script-modules/index.php
@@ -7,6 +7,21 @@
  */

 return array(
+	array(
+		'id' => '@wordpress/interactivity',
+		'path' => 'interactivity/index',
+		'asset' => 'interactivity/index.min.asset.php',
+	),
+	array(
+		'id' => '@wordpress/latex-to-mathml',
+		'path' => 'latex-to-mathml/index',
+		'asset' => 'latex-to-mathml/index.min.asset.php',
+	),
+	array(
+		'id' => '@wordpress/latex-to-mathml/loader',
+		'path' => 'latex-to-mathml/loader',
+		'asset' => 'latex-to-mathml/loader.min.asset.php',
+	),
 	array(
 		'id' => '@wordpress/interactivity-router',
 		'path' => 'interactivity-router/index',
@@ -18,9 +33,9 @@ return array(
 		'asset' => 'interactivity-router/full-page.min.asset.php',
 	),
 	array(
-		'id' => '@wordpress/core-abilities',
-		'path' => 'core-abilities/index',
-		'asset' => 'core-abilities/index.min.asset.php',
+		'id' => '@wordpress/abilities',
+		'path' => 'abilities/index',
+		'asset' => 'abilities/index.min.asset.php',
 	),
 	array(
 		'id' => '@wordpress/a11y',
@@ -28,30 +43,15 @@ return array(
 		'asset' => 'a11y/index.min.asset.php',
 	),
 	array(
-		'id' => '@wordpress/interactivity',
-		'path' => 'interactivity/index',
-		'asset' => 'interactivity/index.min.asset.php',
-	),
-	array(
-		'id' => '@wordpress/abilities',
-		'path' => 'abilities/index',
-		'asset' => 'abilities/index.min.asset.php',
+		'id' => '@wordpress/core-abilities',
+		'path' => 'core-abilities/index',
+		'asset' => 'core-abilities/index.min.asset.php',
 	),
 	array(
 		'id' => '@wordpress/route',
 		'path' => 'route/index',
 		'asset' => 'route/index.min.asset.php',
 	),
-	array(
-		'id' => '@wordpress/latex-to-mathml',
-		'path' => 'latex-to-mathml/index',
-		'asset' => 'latex-to-mathml/index.min.asset.php',
-	),
-	array(
-		'id' => '@wordpress/latex-to-mathml/loader',
-		'path' => 'latex-to-mathml/loader',
-		'asset' => 'latex-to-mathml/loader.min.asset.php',
-	),
 	array(
 		'id' => '@wordpress/edit-site-init',
 		'path' => 'edit-site-init/index',
diff --git a/wp-includes/kses.php b/wp-includes/kses.php
index 5c3e3d4021..fd489c06c7 100644
--- a/wp-includes/kses.php
+++ b/wp-includes/kses.php
@@ -2386,7 +2386,13 @@ function wp_filter_global_styles_post( $data ) {
 		$data_to_encode = WP_Theme_JSON::remove_insecure_properties( $decoded_data, 'custom' );

 		$data_to_encode['isGlobalStylesUserThemeJSON'] = true;
-		return wp_slash( wp_json_encode( $data_to_encode ) );
+		/**
+		 * JSON encode the data stored in post content.
+		 * Escape characters that are likely to be mangled by HTML filters: "<>&".
+		 *
+		 * This matches the escaping in {@see WP_REST_Global_Styles_Controller::prepare_item_for_database()}.
+		 */
+		return wp_slash( wp_json_encode( $data_to_encode, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) );
 	}
 	return $data;
 }
diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-global-styles-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-global-styles-controller.php
index 2a3d4d340d..e5f71ce3c7 100644
--- a/wp-includes/rest-api/endpoints/class-wp-rest-global-styles-controller.php
+++ b/wp-includes/rest-api/endpoints/class-wp-rest-global-styles-controller.php
@@ -275,7 +275,14 @@ class WP_REST_Global_Styles_Controller extends WP_REST_Posts_Controller {
 			}
 			$config['isGlobalStylesUserThemeJSON'] = true;
 			$config['version']                     = WP_Theme_JSON::LATEST_SCHEMA;
-			$changes->post_content                 = wp_json_encode( $config );
+			/**
+			 * JSON encode the data stored in post content.
+			 * Escape characters that are likely to be mangled by HTML filters: "<>&".
+			 *
+			 * This data is later re-encoded by {@see wp_filter_global_styles_post()}.
+			 * The escaping is also applied here as a precaution.
+			 */
+			$changes->post_content = wp_json_encode( $config, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP );
 		}

 		// Post title.
@@ -659,22 +666,87 @@ class WP_REST_Global_Styles_Controller extends WP_REST_Posts_Controller {
 	/**
 	 * Validate style.css as valid CSS.
 	 *
-	 * Currently just checks for invalid markup.
+	 * Currently just checks that CSS will not break an HTML STYLE tag.
 	 *
 	 * @since 6.2.0
 	 * @since 6.4.0 Changed method visibility to protected.
+	 * @since 7.0.0 Only restricts contents which risk prematurely closing the STYLE element,
+	 *              either through a STYLE end tag or a prefix of one which might become a
+	 *              full end tag when combined with the contents of other styles.
 	 *
 	 * @param string $css CSS to validate.
 	 * @return true|WP_Error True if the input was validated, otherwise WP_Error.
 	 */
 	protected function validate_custom_css( $css ) {
-		if ( preg_match( '#</?\w+#', $css ) ) {
-			return new WP_Error(
-				'rest_custom_css_illegal_markup',
-				__( 'Markup is not allowed in CSS.' ),
-				array( 'status' => 400 )
+		$length = strlen( $css );
+		for (
+			$at = strcspn( $css, '<' );
+			$at < $length;
+			$at += strcspn( $css, '<', ++$at )
+		) {
+			$remaining_strlen = $length - $at;
+			/**
+			 * Custom CSS text is expected to render inside an HTML STYLE element.
+			 * A STYLE closing tag must not appear within the CSS text because it
+			 * would close the element prematurely.
+			 *
+			 * The text must also *not* end with a partial closing tag (e.g., `<`,
+			 * `</`, … `</style`) because subsequent styles which are concatenated
+			 * could complete it, forming a valid `</style>` tag.
+			 *
+			 * Example:
+			 *
+			 *     $style_a = 'p { font-weight: bold; </sty';
+			 *     $style_b = 'le> gotcha!';
+			 *     $combined = "{$style_a}{$style_b}";
+			 *
+			 *     $style_a = 'p { font-weight: bold; </style';
+			 *     $style_b = 'p > b { color: red; }';
+			 *     $combined = "{$style_a}\n{$style_b}";
+			 *
+			 * Note how in the second example, both of the style contents are benign
+			 * when analyzed on their own. The first style was likely the result of
+			 * improper truncation, while the second is perfectly sound. It was only
+			 * through concatenation that these two scripts combined to form content
+			 * that would have broken out of the containing STYLE element, thus
+			 * corrupting the page and potentially introducing security issues.
+			 *
+			 * @see https://html.spec.whatwg.org/multipage/parsing.html#rawtext-end-tag-name-state
+			 */
+			$possible_style_close_tag = 0 === substr_compare(
+				$css,
+				'</style',
+				$at,
+				min( 7, $remaining_strlen ),
+				true
 			);
+			if ( $possible_style_close_tag ) {
+				if ( $remaining_strlen < 8 ) {
+					return new WP_Error(
+						'rest_custom_css_illegal_markup',
+						sprintf(
+							/* translators: %s is the CSS that was provided. */
+							__( 'The CSS must not end in "%s".' ),
+							esc_html( substr( $css, $at ) )
+						),
+						array( 'status' => 400 )
+					);
+				}
+
+				if ( 1 === strspn( $css, " \t\f\r\n/>", $at + 7, 1 ) ) {
+					return new WP_Error(
+						'rest_custom_css_illegal_markup',
+						sprintf(
+							/* translators: %s is the CSS that was provided. */
+							__( 'The CSS must not contain "%s".' ),
+							esc_html( substr( $css, $at, 8 ) )
+						),
+						array( 'status' => 400 )
+					);
+				}
+			}
 		}
+
 		return true;
 	}
 }
diff --git a/wp-includes/version.php b/wp-includes/version.php
index 99d0f66e2c..a37945731e 100644
--- a/wp-includes/version.php
+++ b/wp-includes/version.php
@@ -16,7 +16,7 @@
  *
  * @global string $wp_version
  */
-$wp_version = '7.0-alpha-61485';
+$wp_version = '7.0-alpha-61486';

 /**
  * Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema.