diff --git a/src/wp-includes/class-wp-theme-json.php b/src/wp-includes/class-wp-theme-json.php
index f9965a754989a..27b38ddfe90e5 100644
--- a/src/wp-includes/class-wp-theme-json.php
+++ b/src/wp-includes/class-wp-theme-json.php
@@ -1346,7 +1346,7 @@ public function get_settings() {
* }
* @return string The resulting stylesheet.
*/
- public function get_stylesheet( $types = array( 'variables', 'styles', 'presets' ), $origins = null, $options = array() ) {
+ public function get_stylesheet( $types = array( 'variables', 'styles', 'presets' ), ?array $origins = null, $options = array() ) {
if ( null === $origins ) {
$origins = static::VALID_ORIGINS;
}
@@ -1438,7 +1438,7 @@ protected function process_blocks_custom_css( $css, $selector ) {
if ( empty( $part ) ) {
continue;
}
- $is_root_css = ( ! str_contains( $part, '{' ) );
+ $is_root_css = ! str_contains( $part, '{' );
if ( $is_root_css ) {
// If the part doesn't contain braces, it applies to the root level.
$processed_css .= ':root :where(' . trim( $selector ) . '){' . trim( $part ) . '}';
@@ -1881,6 +1881,7 @@ protected function get_css_variables( $nodes, $origins ) {
* creates the corresponding ruleset.
*
* @since 5.8.0
+ * @since 7.0.0 Added sanitization to prevent CSS injection attacks.
*
* @param string $selector CSS selector.
* @param array $declarations List of declarations.
@@ -1891,16 +1892,254 @@ protected static function to_ruleset( $selector, $declarations ) {
return '';
}
+ /*
+ * Sanitize the CSS selector to prevent injection attacks.
+ * Even though selectors typically come from controlled sources,
+ * defense in depth requires treating all inputs as potentially malicious.
+ */
+ $selector = static::sanitize_css_selector( $selector );
+ if ( empty( $selector ) ) {
+ return '';
+ }
+
+ /*
+ * Build the declaration block with sanitized properties.
+ * Each declaration's name and value are sanitized to prevent
+ * CSS injection via malformed property names or values.
+ */
$declaration_block = array_reduce(
$declarations,
static function ( $carry, $element ) {
- return $carry .= $element['name'] . ': ' . $element['value'] . ';'; },
+ // Skip invalid declarations.
+ if ( ! isset( $element['name'] ) || ! isset( $element['value'] ) ) {
+ return $carry;
+ }
+
+ // Sanitize property name and value.
+ $property_name = static::sanitize_css_property_name( $element['name'] );
+ $property_value = static::sanitize_css_property_value( $element['value'] );
+
+ // Only add valid properties to the declaration block.
+ // Allow "0" values (which are valid CSS) but skip null, false, and empty string.
+ if ( ! empty( $property_name ) && ( null !== $property_value && false !== $property_value && '' !== $property_value ) ) {
+ $carry .= $property_name . ': ' . $property_value . ';';
+ }
+
+ return $carry;
+ },
''
);
+ // If all declarations were invalid, return empty string.
+ if ( empty( $declaration_block ) ) {
+ return '';
+ }
+
return $selector . '{' . $declaration_block . '}';
}
+ /**
+ * Sanitizes a CSS selector to prevent injection attacks.
+ *
+ * @since 7.0.0
+ *
+ * @param string $selector CSS selector to sanitize.
+ * @return string Sanitized CSS selector, or empty string if invalid.
+ */
+ protected static function sanitize_css_selector( $selector ) {
+ if ( ! is_string( $selector ) ) {
+ return '';
+ }
+
+ // Remove null bytes.
+ $selector = str_replace( "\0", '', $selector );
+
+ // Remove control characters.
+ $selector = preg_replace( '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $selector );
+
+ /*
+ * Block characters that could break out of the selector context.
+ * These characters can be used to inject new CSS rules.
+ */
+ $dangerous_chars = array(
+ '{', // Opens declaration block.
+ '}', // Closes declaration block.
+ ';', // Ends declaration (shouldn't be in selector).
+ );
+
+ foreach ( $dangerous_chars as $char ) {
+ if ( str_contains( $selector, $char ) ) {
+ return '';
+ }
+ }
+
+ // Block CSS comments.
+ if ( str_contains( $selector, '/*' ) || str_contains( $selector, '*/' ) ) {
+ return '';
+ }
+
+ // Block @-rules in selectors.
+ if ( preg_match( '/@(import|charset|namespace|media|supports|keyframes|font-face)/i', $selector ) ) {
+ return '';
+ }
+
+ // Normalize whitespace.
+ $selector = preg_replace( '/\s+/', ' ', $selector );
+ $selector = trim( $selector );
+
+ // Enforce reasonable length limit.
+ if ( strlen( $selector ) > 1000 ) {
+ return '';
+ }
+
+ return $selector;
+ }
+
+ /**
+ * Sanitizes a CSS property name to prevent injection attacks.
+ *
+ * @since 7.0.0
+ *
+ * @param string $property_name CSS property name to sanitize.
+ * @return string Sanitized CSS property name, or empty string if invalid.
+ */
+ protected static function sanitize_css_property_name( $property_name ) {
+ if ( ! is_string( $property_name ) ) {
+ return '';
+ }
+
+ // Remove null bytes.
+ $property_name = str_replace( "\0", '', $property_name );
+
+ /*
+ * CSS property names should only contain:
+ * - Letters (a-z, A-Z)
+ * - Hyphens (including leading -- for custom properties)
+ * - Numbers (0-9)
+ */
+ $property_name = strtolower( $property_name );
+ $property_name = str_replace( '_', '-', $property_name );
+
+ // Remove invalid characters.
+ $property_name = preg_replace( '/[^a-z0-9-]/', '', $property_name );
+
+ // Validate length.
+ if ( empty( $property_name ) || strlen( $property_name ) > 200 ) {
+ return '';
+ }
+
+ return $property_name;
+ }
+
+ /**
+ * Sanitizes a CSS property value to prevent injection attacks.
+ *
+ * Uses the same sanitization logic as compute_theme_vars() to ensure
+ * consistent security across all CSS generation methods.
+ *
+ * @since 7.0.0
+ *
+ * @param string $property_value CSS property value to sanitize.
+ * @return string Sanitized CSS property value, or empty string if invalid.
+ */
+ protected static function sanitize_css_property_value( $property_value ) {
+ if ( ! is_scalar( $property_value ) ) {
+ return '';
+ }
+
+ // Convert to string.
+ $value = (string) $property_value;
+
+ // Remove null bytes and control characters.
+ $value = str_replace( "\0", '', $value );
+ $value = preg_replace( '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $value );
+ $value = trim( $value );
+
+ if ( '' === $value ) {
+ return '';
+ }
+
+ // Enforce maximum length to prevent DoS.
+ if ( strlen( $value ) > 2000 ) {
+ return '';
+ }
+
+ /*
+ * Quote-aware character filtering.
+ * Removes CSS structure-breaking characters outside of quoted strings.
+ * This preserves legitimate CSS like calc(), var(), and quoted font names
+ * while blocking injection attempts.
+ */
+ $sanitized_value = '';
+ $in_single_quote = false;
+ $in_double_quote = false;
+ $escaped = false;
+ $length = strlen( $value );
+
+ for ( $i = 0; $i < $length; $i++ ) {
+ $char = $value[ $i ];
+
+ if ( $escaped ) {
+ $sanitized_value .= $char;
+ $escaped = false;
+ continue;
+ }
+
+ if ( '\\' === $char ) {
+ $sanitized_value .= $char;
+ $escaped = $in_single_quote || $in_double_quote;
+ continue;
+ }
+
+ if ( '"' === $char && ! $in_single_quote ) {
+ $in_double_quote = ! $in_double_quote;
+ $sanitized_value .= $char;
+ continue;
+ }
+
+ if ( "'" === $char && ! $in_double_quote ) {
+ $in_single_quote = ! $in_single_quote;
+ $sanitized_value .= $char;
+ continue;
+ }
+
+ /*
+ * Remove CSS structure characters outside quotes.
+ * Prevents breaking out of the declaration to inject new rules.
+ */
+ if ( ! $in_single_quote && ! $in_double_quote && ( '{' === $char || '}' === $char || ';' === $char ) ) {
+ continue;
+ }
+
+ $sanitized_value .= $char;
+ }
+
+ $sanitized_value = trim( $sanitized_value );
+
+ // Normalize whitespace to prevent word injection.
+ $sanitized_value = preg_replace( '/\s+/', ' ', $sanitized_value );
+
+ if ( '' === $sanitized_value ) {
+ return '';
+ }
+
+ // Block dangerous URL protocols.
+ if ( preg_match( '/url\s*\(\s*["\']?\s*(javascript|data|vbscript):/i', $sanitized_value ) ) {
+ return '';
+ }
+
+ // Block CSS at-rules.
+ if ( preg_match( '/@(import|charset|namespace)/i', $sanitized_value ) ) {
+ return '';
+ }
+
+ // Block legacy browser attack vectors.
+ if ( preg_match( '/(expression|behavior|-moz-binding)\s*\(/i', $sanitized_value ) ) {
+ return '';
+ }
+
+ return $sanitized_value;
+ }
/**
* Given a settings array, returns the generated rulesets
* for the preset classes.
@@ -2109,7 +2348,7 @@ protected static function get_settings_values_by_slug( $settings, $preset_metada
* @param string[] $origins List of origins to process.
* @return array Array of presets where the key and value are both the slug.
*/
- protected static function get_settings_slugs( $settings, $preset_metadata, $origins = null ) {
+ protected static function get_settings_slugs( $settings, $preset_metadata, ?array $origins = null ) {
if ( null === $origins ) {
$origins = static::VALID_ORIGINS;
}
@@ -2190,6 +2429,7 @@ protected static function compute_preset_vars( $settings, $origins ) {
* )
*
* @since 5.8.0
+ * @since 7.0.0 Added comprehensive sanitization to prevent CSS injection attacks.
*
* @param array $settings Settings to process.
* @return array The modified $declarations.
@@ -2198,10 +2438,128 @@ protected static function compute_theme_vars( $settings ) {
$declarations = array();
$custom_values = $settings['custom'] ?? array();
$css_vars = static::flatten_tree( $custom_values );
+
foreach ( $css_vars as $key => $value ) {
+ /*
+ * Sanitize the CSS variable name.
+ * Ensures only lowercase alphanumeric characters and hyphens are allowed.
+ * Prevents CSS injection via malformed variable names.
+ */
+ $key = sanitize_key( $key );
+ $key = str_replace( '_', '-', $key );
+ $key = preg_replace( '/[^a-z0-9-]/', '', $key );
+ if ( '' === $key ) {
+ continue;
+ }
+
+ // Only process scalar values.
+ if ( ! is_scalar( $value ) ) {
+ continue;
+ }
+
+ /*
+ * Initial value sanitization.
+ * Remove HTML tags, angle brackets, and control characters.
+ */
+ $value = wp_strip_all_tags( (string) $value, true );
+ $value = str_replace( array( '<', '>' ), '', $value );
+ $value = preg_replace( '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $value );
+ $value = trim( $value );
+
+ if ( '' === $value ) {
+ continue;
+ }
+
+ /*
+ * Enforce maximum length to prevent denial of service attacks.
+ * Limits CSS custom property values to 2000 characters.
+ */
+ if ( strlen( $value ) > 2000 ) {
+ continue;
+ }
+
+ /*
+ * Quote-aware character filtering.
+ * Removes CSS structure-breaking characters (semicolons, braces) when outside quotes.
+ * Preserves legitimate CSS syntax within quoted strings.
+ */
+ $sanitized_value = '';
+ $in_single_quote = false;
+ $in_double_quote = false;
+ $escaped = false;
+ $length = strlen( $value );
+
+ for ( $i = 0; $i < $length; $i++ ) {
+ $char = $value[ $i ];
+
+ if ( $escaped ) {
+ $sanitized_value .= $char;
+ $escaped = false;
+ continue;
+ }
+
+ if ( '\\' === $char ) {
+ $sanitized_value .= $char;
+ $escaped = $in_single_quote || $in_double_quote;
+ continue;
+ }
+
+ if ( '"' === $char && ! $in_single_quote ) {
+ $in_double_quote = ! $in_double_quote;
+ $sanitized_value .= $char;
+ continue;
+ }
+
+ if ( "'" === $char && ! $in_double_quote ) {
+ $in_single_quote = ! $in_single_quote;
+ $sanitized_value .= $char;
+ continue;
+ }
+
+ /*
+ * Strip CSS structure characters outside of quotes.
+ * Prevents breaking out of CSS declarations to inject malicious rules.
+ */
+ if ( ! $in_single_quote && ! $in_double_quote && ( ';' === $char || '{' === $char || '}' === $char ) ) {
+ continue;
+ }
+
+ $sanitized_value .= $char;
+ }
+
+ $sanitized_value = trim( $sanitized_value );
+
+ if ( '' === $sanitized_value ) {
+ continue;
+ }
+
+ /*
+ * Block dangerous URL protocols.
+ * Prevents XSS attacks via javascript:, data:, and vbscript: URLs.
+ */
+ if ( preg_match( '/url\s*\(\s*["\']?\s*(javascript|data|vbscript):/i', $sanitized_value ) ) {
+ continue;
+ }
+
+ /*
+ * Block CSS at-rules.
+ * Prevents injection of @import, @charset, and @namespace rules.
+ */
+ if ( preg_match( '/@(import|charset|namespace)/i', $sanitized_value ) ) {
+ continue;
+ }
+
+ /*
+ * Block legacy browser attack vectors.
+ * Prevents exploitation via IE expressions, behaviors, and Firefox XBL bindings.
+ */
+ if ( preg_match( '/(expression|behavior|-moz-binding)\s*\(/i', $sanitized_value ) ) {
+ continue;
+ }
+
$declarations[] = array(
'name' => '--wp--custom--' . $key,
- 'value' => $value,
+ 'value' => $sanitized_value,
);
}
@@ -2291,7 +2649,7 @@ protected static function flatten_tree( $tree, $prefix = '', $token = '--' ) {
* @param boolean $use_root_padding Whether to add custom properties at root level.
* @return array Returns the modified $declarations.
*/
- protected static function compute_style_properties( $styles, $settings = array(), $properties = null, $theme_json = null, $selector = null, $use_root_padding = null ) {
+ protected static function compute_style_properties( $styles, $settings = array(), $properties = null, ?array $theme_json = null, ?string $selector = null, ?bool $use_root_padding = null ) {
if ( empty( $styles ) ) {
return array();
}
@@ -2428,7 +2786,7 @@ protected static function compute_style_properties( $styles, $settings = array()
* @param array $theme_json Theme JSON array.
* @return string|array Style property value.
*/
- protected static function get_property_value( $styles, $path, $theme_json = null ) {
+ protected static function get_property_value( $styles, $path, ?array $theme_json = null ) {
$value = _wp_array_get( $styles, $path, '' );
if ( '' === $value || null === $value ) {
@@ -2454,8 +2812,8 @@ protected static function get_property_value( $styles, $path, $theme_json = null
}
if ( is_array( $ref_value ) && isset( $ref_value['ref'] ) ) {
- $path_string = json_encode( $path );
- $ref_value_string = json_encode( $ref_value );
+ $path_string = wp_json_encode( $path );
+ $ref_value_string = wp_json_encode( $ref_value );
_doing_it_wrong(
'get_property_value',
sprintf(
@@ -3749,7 +4107,9 @@ protected static function remove_insecure_styles( $input ) {
protected static function is_safe_css_declaration( $property_name, $property_value ) {
$style_to_validate = $property_name . ': ' . $property_value;
$filtered = esc_html( safecss_filter_attr( $style_to_validate ) );
- return ! empty( trim( $filtered ) );
+ $trimmed = trim( $filtered );
+ // Allow "0" values (which are valid CSS) but skip empty strings.
+ return '' !== $trimmed;
}
/**
@@ -4479,7 +4839,7 @@ private static function convert_variables_to_value( $styles, $values ) {
continue;
}
- if ( 0 <= strpos( $style, 'var(' ) ) {
+ if ( str_contains( $style, 'var(' ) ) {
// find all the variables in the string in the form of var(--variable-name, fallback), with fallback in the second capture group.
$has_matches = preg_match_all( '/var\(([^),]+)?,?\s?(\S+)?\)/', $style, $var_parts );
diff --git a/tests/phpunit/tests/theme/wpThemeJsonComputeThemeVars.php b/tests/phpunit/tests/theme/wpThemeJsonComputeThemeVars.php
new file mode 100644
index 0000000000000..b5cd1e556f526
--- /dev/null
+++ b/tests/phpunit/tests/theme/wpThemeJsonComputeThemeVars.php
@@ -0,0 +1,457 @@
+setAccessible( true );
+ return $method->invoke( null, $settings );
+ }
+
+ /**
+ * Test that CSS variable names are properly sanitized.
+ *
+ * @ticket 62224
+ */
+ public function test_compute_theme_vars_sanitizes_variable_names() {
+ $settings = array(
+ 'custom' => array(
+ 'color}body{background' => 'red',
+ 'valid-name' => 'blue',
+ 'UPPERCASE' => 'green',
+ 'with_underscores' => 'yellow',
+ 'special!@#chars' => 'purple',
+ ),
+ );
+
+ $result = $this->compute_theme_vars( $settings );
+ $vars = array();
+ foreach ( $result as $declaration ) {
+ $vars[ $declaration['name'] ] = $declaration['value'];
+ }
+
+ $this->assertCount( 5, $result );
+ $this->assertArrayHasKey( '--wp--custom--color-body-background', $vars );
+ $this->assertSame( 'red', $vars['--wp--custom--color-body-background'] );
+ $this->assertSame( 'blue', $vars['--wp--custom--valid-name'] );
+ $this->assertSame( 'green', $vars['--wp--custom--uppercase'] );
+ $this->assertSame( 'yellow', $vars['--wp--custom--with-underscores'] );
+ $this->assertSame( 'purple', $vars['--wp--custom--special-chars'] );
+ }
+
+ /**
+ * Test that CSS injection via semicolons is blocked.
+ *
+ * @ticket 62224
+ */
+ public function test_compute_theme_vars_blocks_semicolon_injection() {
+ $settings = array(
+ 'custom' => array(
+ 'color' => 'red; } body { display: none; } /*',
+ ),
+ );
+
+ $result = $this->compute_theme_vars( $settings );
+
+ $this->assertCount( 1, $result );
+ $this->assertSame( '--wp--custom--color', $result[0]['name'] );
+ // Semicolons and braces outside quotes should be stripped.
+ $this->assertStringNotContainsString( ';', $result[0]['value'] );
+ $this->assertStringNotContainsString( '{', $result[0]['value'] );
+ $this->assertStringNotContainsString( '}', $result[0]['value'] );
+ }
+
+ /**
+ * Test that CSS injection via braces is blocked.
+ *
+ * @ticket 62224
+ */
+ public function test_compute_theme_vars_blocks_brace_injection() {
+ $settings = array(
+ 'custom' => array(
+ 'evil' => 'x; } * { background: red; } /*',
+ ),
+ );
+
+ $result = $this->compute_theme_vars( $settings );
+
+ $this->assertCount( 1, $result );
+ // Braces should be removed.
+ $this->assertStringNotContainsString( '{', $result[0]['value'] );
+ $this->assertStringNotContainsString( '}', $result[0]['value'] );
+ }
+
+ /**
+ * Test that javascript: URLs are blocked.
+ *
+ * @ticket 62224
+ */
+ public function test_compute_theme_vars_blocks_javascript_urls() {
+ $settings = array(
+ 'custom' => array(
+ 'bg1' => 'url(javascript:alert(1))',
+ 'bg2' => 'url("javascript:alert(2)")',
+ 'bg3' => "url('javascript:alert(3)')",
+ ),
+ );
+
+ $result = $this->compute_theme_vars( $settings );
+
+ // All javascript: URLs should be blocked.
+ $this->assertCount( 0, $result );
+ }
+
+ /**
+ * Test that data: URLs are blocked.
+ *
+ * @ticket 62224
+ */
+ public function test_compute_theme_vars_blocks_data_urls() {
+ $settings = array(
+ 'custom' => array(
+ 'bg' => 'url(data:text/html,)',
+ ),
+ );
+
+ $result = $this->compute_theme_vars( $settings );
+
+ // data: URLs should be blocked.
+ $this->assertCount( 0, $result );
+ }
+
+ /**
+ * Test that vbscript: URLs are blocked.
+ *
+ * @ticket 62224
+ */
+ public function test_compute_theme_vars_blocks_vbscript_urls() {
+ $settings = array(
+ 'custom' => array(
+ 'bg' => 'url(vbscript:msgbox(1))',
+ ),
+ );
+
+ $result = $this->compute_theme_vars( $settings );
+
+ // vbscript: URLs should be blocked.
+ $this->assertCount( 0, $result );
+ }
+
+ /**
+ * Test that @import rules are blocked.
+ *
+ * @ticket 62224
+ */
+ public function test_compute_theme_vars_blocks_import_rules() {
+ $settings = array(
+ 'custom' => array(
+ 'evil' => 'x; } @import url(evil.com/malicious.css); /*',
+ ),
+ );
+
+ $result = $this->compute_theme_vars( $settings );
+
+ // @import should be blocked.
+ $this->assertCount( 0, $result );
+ }
+
+ /**
+ * Test that @charset rules are blocked.
+ *
+ * @ticket 62224
+ */
+ public function test_compute_theme_vars_blocks_charset_rules() {
+ $settings = array(
+ 'custom' => array(
+ 'evil' => '@charset "UTF-8";',
+ ),
+ );
+
+ $result = $this->compute_theme_vars( $settings );
+
+ // @charset should be blocked.
+ $this->assertCount( 0, $result );
+ }
+
+ /**
+ * Test that IE expression() is blocked.
+ *
+ * @ticket 62224
+ */
+ public function test_compute_theme_vars_blocks_ie_expressions() {
+ $settings = array(
+ 'custom' => array(
+ 'width' => 'expression(alert(1))',
+ ),
+ );
+
+ $result = $this->compute_theme_vars( $settings );
+
+ // IE expressions should be blocked.
+ $this->assertCount( 0, $result );
+ }
+
+ /**
+ * Test that behavior is blocked.
+ *
+ * @ticket 62224
+ */
+ public function test_compute_theme_vars_blocks_behavior() {
+ $settings = array(
+ 'custom' => array(
+ 'evil' => 'behavior(url(evil.htc))',
+ ),
+ );
+
+ $result = $this->compute_theme_vars( $settings );
+
+ // behavior should be blocked.
+ $this->assertCount( 0, $result );
+ }
+
+ /**
+ * Test that -moz-binding is blocked.
+ *
+ * @ticket 62224
+ */
+ public function test_compute_theme_vars_blocks_moz_binding() {
+ $settings = array(
+ 'custom' => array(
+ 'evil' => '-moz-binding(url(evil.xml))',
+ ),
+ );
+
+ $result = $this->compute_theme_vars( $settings );
+
+ // -moz-binding should be blocked.
+ $this->assertCount( 0, $result );
+ }
+
+ /**
+ * Test that excessively long values are blocked (DoS prevention).
+ *
+ * @ticket 62224
+ */
+ public function test_compute_theme_vars_blocks_long_values() {
+ $settings = array(
+ 'custom' => array(
+ 'long' => str_repeat( 'a', 3000 ),
+ ),
+ );
+
+ $result = $this->compute_theme_vars( $settings );
+
+ // Values over 2000 characters should be blocked.
+ $this->assertCount( 0, $result );
+ }
+
+ /**
+ * Test that legitimate calc() values are preserved.
+ *
+ * @ticket 62224
+ */
+ public function test_compute_theme_vars_preserves_calc() {
+ $settings = array(
+ 'custom' => array(
+ 'spacing' => 'calc(100% - 20px)',
+ ),
+ );
+
+ $result = $this->compute_theme_vars( $settings );
+
+ $this->assertCount( 1, $result );
+ $this->assertSame( 'calc(100% - 20px)', $result[0]['value'] );
+ }
+
+ /**
+ * Test that legitimate var() values are preserved.
+ *
+ * @ticket 62224
+ */
+ public function test_compute_theme_vars_preserves_var() {
+ $settings = array(
+ 'custom' => array(
+ 'color' => 'var(--wp--preset--color--primary)',
+ ),
+ );
+
+ $result = $this->compute_theme_vars( $settings );
+
+ $this->assertCount( 1, $result );
+ $this->assertSame( 'var(--wp--preset--color--primary)', $result[0]['value'] );
+ }
+
+ /**
+ * Test that legitimate gradient values are preserved.
+ *
+ * @ticket 62224
+ */
+ public function test_compute_theme_vars_preserves_gradients() {
+ $settings = array(
+ 'custom' => array(
+ 'gradient' => 'linear-gradient(90deg, #ff0000, #0000ff)',
+ ),
+ );
+
+ $result = $this->compute_theme_vars( $settings );
+
+ $this->assertCount( 1, $result );
+ $this->assertSame( 'linear-gradient(90deg, #ff0000, #0000ff)', $result[0]['value'] );
+ }
+
+ /**
+ * Test that legitimate quoted font names are preserved.
+ *
+ * @ticket 62224
+ */
+ public function test_compute_theme_vars_preserves_quoted_values() {
+ $settings = array(
+ 'custom' => array(
+ 'font' => '"Times New Roman", serif',
+ ),
+ );
+
+ $result = $this->compute_theme_vars( $settings );
+
+ $this->assertCount( 1, $result );
+ $this->assertSame( '"Times New Roman", serif', $result[0]['value'] );
+ }
+
+ /**
+ * Test that semicolons inside quoted strings are preserved.
+ *
+ * @ticket 62224
+ */
+ public function test_compute_theme_vars_preserves_semicolons_in_quotes() {
+ $settings = array(
+ 'custom' => array(
+ 'content' => '"Hello; World"',
+ ),
+ );
+
+ $result = $this->compute_theme_vars( $settings );
+
+ $this->assertCount( 1, $result );
+ $this->assertSame( '"Hello; World"', $result[0]['value'] );
+ }
+
+ /**
+ * Test that braces inside quoted strings are preserved.
+ *
+ * @ticket 62224
+ */
+ public function test_compute_theme_vars_preserves_braces_in_quotes() {
+ $settings = array(
+ 'custom' => array(
+ 'content' => '"{not a CSS rule}"',
+ ),
+ );
+
+ $result = $this->compute_theme_vars( $settings );
+
+ $this->assertCount( 1, $result );
+ $this->assertSame( '"{not a CSS rule}"', $result[0]['value'] );
+ }
+
+ /**
+ * Test that legitimate HTTPS URLs are preserved.
+ *
+ * @ticket 62224
+ */
+ public function test_compute_theme_vars_preserves_https_urls() {
+ $settings = array(
+ 'custom' => array(
+ 'bg' => 'url(https://example.com/image.jpg)',
+ ),
+ );
+
+ $result = $this->compute_theme_vars( $settings );
+
+ $this->assertCount( 1, $result );
+ $this->assertSame( 'url(https://example.com/image.jpg)', $result[0]['value'] );
+ }
+
+ /**
+ * Test that non-scalar values are ignored.
+ *
+ * @ticket 62224
+ */
+ public function test_compute_theme_vars_ignores_non_scalar_values() {
+ $settings = array(
+ 'custom' => array(
+ 'array' => array( 'nested' => 'value' ),
+ 'object' => new stdClass(),
+ ),
+ );
+
+ $result = $this->compute_theme_vars( $settings );
+
+ // Nested arrays should be flattened and processed (1 result).
+ // Objects should be ignored (non-scalar after flattening).
+ $this->assertCount( 1, $result );
+ $this->assertSame( '--wp--custom--array--nested', $result[0]['name'] );
+ $this->assertSame( 'value', $result[0]['value'] );
+ }
+
+ /**
+ * Test that empty values are ignored.
+ *
+ * @ticket 62224
+ */
+ public function test_compute_theme_vars_ignores_empty_values() {
+ $settings = array(
+ 'custom' => array(
+ 'empty1' => '',
+ 'empty2' => ' ',
+ ),
+ );
+
+ $result = $this->compute_theme_vars( $settings );
+
+ // Empty values should be ignored.
+ $this->assertCount( 0, $result );
+ }
+
+ /**
+ * Test that HTML tags are stripped from values.
+ *
+ * @ticket 62224
+ */
+ public function test_compute_theme_vars_strips_html_tags() {
+ $settings = array(
+ 'custom' => array(
+ 'color' => 'red',
+ ),
+ );
+
+ $result = $this->compute_theme_vars( $settings );
+
+ $this->assertCount( 1, $result );
+ $this->assertStringNotContainsString( '', $result[0]['value'] );
+ }
+
+ /**
+ * Test that control characters are removed.
+ *
+ * @ticket 62224
+ */
+ public function test_compute_theme_vars_removes_control_characters() {
+ $settings = array(
+ 'custom' => array(
+ 'color' => "red\x00\x1F",
+ ),
+ );
+
+ $result = $this->compute_theme_vars( $settings );
+
+ $this->assertCount( 1, $result );
+ $this->assertSame( 'red', $result[0]['value'] );
+ }
+}
diff --git a/tests/phpunit/tests/theme/wpThemeJsonToRuleset.php b/tests/phpunit/tests/theme/wpThemeJsonToRuleset.php
new file mode 100644
index 0000000000000..b6dfd1e871872
--- /dev/null
+++ b/tests/phpunit/tests/theme/wpThemeJsonToRuleset.php
@@ -0,0 +1,572 @@
+setAccessible( true );
+ return $method->invoke( null, $selector, $declarations );
+ }
+
+ /**
+ * Test that to_ruleset generates valid CSS.
+ *
+ * @ticket 62224
+ */
+ public function test_to_ruleset_generates_valid_css() {
+ $selector = '.wp-block-test';
+ $declarations = array(
+ array(
+ 'name' => 'color',
+ 'value' => 'red',
+ ),
+ array(
+ 'name' => 'font-size',
+ 'value' => '16px',
+ ),
+ );
+
+ $result = $this->to_ruleset( $selector, $declarations );
+
+ $this->assertStringContainsString( '.wp-block-test{', $result );
+ $this->assertStringContainsString( 'color: red;', $result );
+ $this->assertStringContainsString( 'font-size: 16px;', $result );
+ }
+
+ /**
+ * Test that selector injection via braces is blocked.
+ *
+ * @ticket 62224
+ */
+ public function test_to_ruleset_blocks_selector_injection_braces() {
+ $selector = '.test } body { background: red; } .fake {';
+ $declarations = array(
+ array(
+ 'name' => 'color',
+ 'value' => 'blue',
+ ),
+ );
+
+ $result = $this->to_ruleset( $selector, $declarations );
+
+ // Malicious selector should be rejected.
+ $this->assertSame( '', $result );
+ }
+
+ /**
+ * Test that selector injection via semicolons is blocked.
+ *
+ * @ticket 62224
+ */
+ public function test_to_ruleset_blocks_selector_injection_semicolons() {
+ $selector = '.test; } body { display: none; } .fake';
+ $declarations = array(
+ array(
+ 'name' => 'color',
+ 'value' => 'blue',
+ ),
+ );
+
+ $result = $this->to_ruleset( $selector, $declarations );
+
+ // Malicious selector should be rejected.
+ $this->assertSame( '', $result );
+ }
+
+ /**
+ * Test that CSS comments in selectors are blocked.
+ *
+ * @ticket 62224
+ */
+ public function test_to_ruleset_blocks_selector_comments() {
+ $selector = '.test /* comment */ .nested';
+ $declarations = array(
+ array(
+ 'name' => 'color',
+ 'value' => 'blue',
+ ),
+ );
+
+ $result = $this->to_ruleset( $selector, $declarations );
+
+ // Selectors with comments should be rejected.
+ $this->assertSame( '', $result );
+ }
+
+ /**
+ * Test that @-rules in selectors are blocked.
+ *
+ * @ticket 62224
+ */
+ public function test_to_ruleset_blocks_selector_at_rules() {
+ $selector = '@media screen { .test';
+ $declarations = array(
+ array(
+ 'name' => 'color',
+ 'value' => 'blue',
+ ),
+ );
+
+ $result = $this->to_ruleset( $selector, $declarations );
+
+ // Selectors with @-rules should be rejected.
+ $this->assertSame( '', $result );
+ }
+
+ /**
+ * Test that excessively long selectors are blocked.
+ *
+ * @ticket 62224
+ */
+ public function test_to_ruleset_blocks_long_selectors() {
+ $selector = str_repeat( '.test-class-name-', 100 );
+ $declarations = array(
+ array(
+ 'name' => 'color',
+ 'value' => 'blue',
+ ),
+ );
+
+ $result = $this->to_ruleset( $selector, $declarations );
+
+ // Excessively long selectors should be rejected.
+ $this->assertSame( '', $result );
+ }
+
+ /**
+ * Test that property name injection is blocked.
+ *
+ * @ticket 62224
+ */
+ public function test_to_ruleset_sanitizes_property_names() {
+ $selector = '.test';
+ $declarations = array(
+ array(
+ 'name' => 'color; } body { background',
+ 'value' => 'blue',
+ ),
+ );
+
+ $result = $this->to_ruleset( $selector, $declarations );
+
+ // Dangerous characters in property name should be removed.
+ // The sanitized name becomes 'colorbodybackground' (no spaces or special chars).
+ $this->assertStringContainsString( 'colorbodybackground: blue;', $result );
+ $this->assertStringNotContainsString( 'color; } body { background', $result );
+ }
+
+ /**
+ * Test that property value injection via braces is blocked.
+ *
+ * @ticket 62224
+ */
+ public function test_to_ruleset_blocks_property_value_injection_braces() {
+ $selector = '.test';
+ $declarations = array(
+ array(
+ 'name' => 'color',
+ 'value' => 'red; } body { background: url(evil.com); } .fake { color',
+ ),
+ );
+
+ $result = $this->to_ruleset( $selector, $declarations );
+
+ // Target only the value part so we don't fail on structural CSS braces
+ if ( preg_match( '/color:\s*([^;]+);/', $result, $matches ) ) {
+ $property_value = $matches[1];
+ $this->assertStringNotContainsString( '{', $property_value, 'Value should not contain opening braces.' );
+ $this->assertStringNotContainsString( '}', $property_value, 'Value should not contain closing braces.' );
+ } else {
+ $this->fail( 'Sanitized property value not found in output.' );
+ }
+ $this->assertStringContainsString( 'color:', $result );
+ }
+
+ /**
+ * Test that javascript: URLs are blocked.
+ *
+ * @ticket 62224
+ */
+ public function test_to_ruleset_blocks_javascript_urls() {
+ $selector = '.test';
+ $declarations = array(
+ array(
+ 'name' => 'background',
+ 'value' => 'url(javascript:alert(1))',
+ ),
+ );
+
+ $result = $this->to_ruleset( $selector, $declarations );
+
+ // Declaration with javascript: URL should be rejected.
+ $this->assertSame( '', $result );
+ }
+
+ /**
+ * Test that data: URLs are blocked.
+ *
+ * @ticket 62224
+ */
+ public function test_to_ruleset_blocks_data_urls() {
+ $selector = '.test';
+ $declarations = array(
+ array(
+ 'name' => 'background',
+ 'value' => 'url(data:text/html,)',
+ ),
+ );
+
+ $result = $this->to_ruleset( $selector, $declarations );
+
+ // Declaration with data: URL should be rejected.
+ $this->assertSame( '', $result );
+ }
+
+ /**
+ * Test that @import is blocked.
+ *
+ * @ticket 62224
+ */
+ public function test_to_ruleset_blocks_import() {
+ $selector = '.test';
+ $declarations = array(
+ array(
+ 'name' => 'font',
+ 'value' => 'Arial; @import url(evil.com);',
+ ),
+ );
+
+ $result = $this->to_ruleset( $selector, $declarations );
+
+ // Declaration with @import should be rejected.
+ $this->assertSame( '', $result );
+ }
+
+ /**
+ * Test that IE expression() is blocked.
+ *
+ * @ticket 62224
+ */
+ public function test_to_ruleset_blocks_ie_expressions() {
+ $selector = '.test';
+ $declarations = array(
+ array(
+ 'name' => 'width',
+ 'value' => 'expression(alert(1))',
+ ),
+ );
+
+ $result = $this->to_ruleset( $selector, $declarations );
+
+ // Declaration with expression() should be rejected.
+ $this->assertSame( '', $result );
+ }
+
+ /**
+ * Test that excessively long values are blocked.
+ *
+ * @ticket 62224
+ */
+ public function test_to_ruleset_blocks_long_values() {
+ $selector = '.test';
+ $declarations = array(
+ array(
+ 'name' => 'color',
+ 'value' => str_repeat( 'a', 3000 ),
+ ),
+ );
+
+ $result = $this->to_ruleset( $selector, $declarations );
+
+ // Excessively long value should be rejected.
+ $this->assertSame( '', $result );
+ }
+
+ /**
+ * Test that calc() is preserved.
+ *
+ * @ticket 62224
+ */
+ public function test_to_ruleset_preserves_calc() {
+ $selector = '.test';
+ $declarations = array(
+ array(
+ 'name' => 'width',
+ 'value' => 'calc(100% - 20px)',
+ ),
+ );
+
+ $result = $this->to_ruleset( $selector, $declarations );
+
+ $this->assertStringContainsString( 'calc(100% - 20px)', $result );
+ }
+
+ /**
+ * Test that var() is preserved.
+ *
+ * @ticket 62224
+ */
+ public function test_to_ruleset_preserves_var() {
+ $selector = '.test';
+ $declarations = array(
+ array(
+ 'name' => 'color',
+ 'value' => 'var(--wp--preset--color--primary)',
+ ),
+ );
+
+ $result = $this->to_ruleset( $selector, $declarations );
+
+ $this->assertStringContainsString( 'var(--wp--preset--color--primary)', $result );
+ }
+
+ /**
+ * Test that gradients are preserved.
+ *
+ * @ticket 62224
+ */
+ public function test_to_ruleset_preserves_gradients() {
+ $selector = '.test';
+ $declarations = array(
+ array(
+ 'name' => 'background',
+ 'value' => 'linear-gradient(90deg, #ff0000, #0000ff)',
+ ),
+ );
+
+ $result = $this->to_ruleset( $selector, $declarations );
+
+ $this->assertStringContainsString( 'linear-gradient(90deg, #ff0000, #0000ff)', $result );
+ }
+
+ /**
+ * Test that quoted strings are preserved.
+ *
+ * @ticket 62224
+ */
+ public function test_to_ruleset_preserves_quoted_strings() {
+ $selector = '.test';
+ $declarations = array(
+ array(
+ 'name' => 'font-family',
+ 'value' => '"Times New Roman", serif',
+ ),
+ );
+
+ $result = $this->to_ruleset( $selector, $declarations );
+
+ $this->assertStringContainsString( '"Times New Roman", serif', $result );
+ }
+
+ /**
+ * Test that braces inside quotes are preserved.
+ *
+ * @ticket 62224
+ */
+ public function test_to_ruleset_preserves_braces_in_quotes() {
+ $selector = '.test';
+ $declarations = array(
+ array(
+ 'name' => 'content',
+ 'value' => '"{not a rule}"',
+ ),
+ );
+
+ $result = $this->to_ruleset( $selector, $declarations );
+
+ $this->assertStringContainsString( '"{not a rule}"', $result );
+ }
+
+ /**
+ * Test that HTTPS URLs are preserved.
+ *
+ * @ticket 62224
+ */
+ public function test_to_ruleset_preserves_https_urls() {
+ $selector = '.test';
+ $declarations = array(
+ array(
+ 'name' => 'background-image',
+ 'value' => 'url(https://example.com/image.jpg)',
+ ),
+ );
+
+ $result = $this->to_ruleset( $selector, $declarations );
+
+ $this->assertStringContainsString( 'url(https://example.com/image.jpg)', $result );
+ }
+
+ /**
+ * Test that pseudo-selectors are preserved.
+ *
+ * @ticket 62224
+ */
+ public function test_to_ruleset_preserves_pseudo_selectors() {
+ $selector = '.test:hover';
+ $declarations = array(
+ array(
+ 'name' => 'color',
+ 'value' => 'blue',
+ ),
+ );
+
+ $result = $this->to_ruleset( $selector, $declarations );
+
+ $this->assertStringContainsString( '.test:hover{', $result );
+ }
+
+ /**
+ * Test that attribute selectors are preserved.
+ *
+ * @ticket 62224
+ */
+ public function test_to_ruleset_preserves_attribute_selectors() {
+ $selector = 'input[type="text"]';
+ $declarations = array(
+ array(
+ 'name' => 'border',
+ 'value' => '1px solid black',
+ ),
+ );
+
+ $result = $this->to_ruleset( $selector, $declarations );
+
+ $this->assertStringContainsString( 'input[type="text"]{', $result );
+ }
+
+ /**
+ * Test that combinators are preserved.
+ *
+ * @ticket 62224
+ */
+ public function test_to_ruleset_preserves_combinators() {
+ $selector = '.parent > .child';
+ $declarations = array(
+ array(
+ 'name' => 'margin',
+ 'value' => '10px',
+ ),
+ );
+
+ $result = $this->to_ruleset( $selector, $declarations );
+
+ $this->assertStringContainsString( '.parent > .child{', $result );
+ }
+
+ /**
+ * Test that empty declarations return empty string.
+ *
+ * @ticket 62224
+ */
+ public function test_to_ruleset_returns_empty_for_empty_declarations() {
+ $selector = '.test';
+ $declarations = array();
+
+ $result = $this->to_ruleset( $selector, $declarations );
+
+ $this->assertSame( '', $result );
+ }
+
+ /**
+ * Test that invalid declarations are skipped.
+ *
+ * @ticket 62224
+ */
+ public function test_to_ruleset_skips_invalid_declarations() {
+ $selector = '.test';
+ $declarations = array(
+ array( 'invalid' => 'format' ),
+ array(
+ 'name' => 'color',
+ 'value' => 'red',
+ ),
+ );
+
+ $result = $this->to_ruleset( $selector, $declarations );
+
+ $this->assertStringContainsString( 'color: red', $result );
+ }
+
+ /**
+ * Test that custom properties are preserved.
+ *
+ * @ticket 62224
+ */
+ public function test_to_ruleset_preserves_custom_properties() {
+ $selector = ':root';
+ $declarations = array(
+ array(
+ 'name' => '--wp--custom--spacing',
+ 'value' => '20px',
+ ),
+ );
+
+ $result = $this->to_ruleset( $selector, $declarations );
+
+ $this->assertStringContainsString( '--wp--custom--spacing: 20px', $result );
+ }
+
+ /**
+ * Test that all invalid declarations result in empty output.
+ *
+ * @ticket 62224
+ */
+ public function test_to_ruleset_returns_empty_for_all_invalid_declarations() {
+ $selector = '.test';
+ $declarations = array(
+ array(
+ 'name' => 'color',
+ 'value' => 'url(javascript:alert(1))',
+ ),
+ array(
+ 'name' => 'background',
+ 'value' => '@import url(evil.com);',
+ ),
+ );
+
+ $result = $this->to_ruleset( $selector, $declarations );
+
+ // All declarations invalid = empty output.
+ $this->assertSame( '', $result );
+ }
+
+ /**
+ * Test mixed valid and invalid declarations.
+ *
+ * @ticket 62224
+ */
+ public function test_to_ruleset_handles_mixed_declarations() {
+ $selector = '.test';
+ $declarations = array(
+ array(
+ 'name' => 'color',
+ 'value' => 'red',
+ ),
+ array(
+ 'name' => 'background',
+ 'value' => 'url(javascript:alert(1))',
+ ),
+ array(
+ 'name' => 'font-size',
+ 'value' => '16px',
+ ),
+ );
+
+ $result = $this->to_ruleset( $selector, $declarations );
+
+ // Valid declarations should be included.
+ $this->assertStringContainsString( 'color: red', $result );
+ $this->assertStringContainsString( 'font-size: 16px', $result );
+ // Invalid declaration should be excluded.
+ $this->assertStringNotContainsString( 'javascript', $result );
+ }
+}