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 ); + } +}