From 7604d2f3f7525895eabda20a9a075093b7c0eb49 Mon Sep 17 00:00:00 2001 From: Jorge Date: Mon, 4 Jan 2021 20:28:46 +0000 Subject: [PATCH 1/4] Add: Save time theme.json escaping. --- lib/class-wp-theme-json-resolver.php | 9 +- lib/class-wp-theme-json.php | 159 +++++++++++++++++- lib/global-styles.php | 109 ++++++++++++ .../editor/global-styles-provider.js | 14 +- 4 files changed, 283 insertions(+), 8 deletions(-) diff --git a/lib/class-wp-theme-json-resolver.php b/lib/class-wp-theme-json-resolver.php index a94dbb1fde86d6..db388b9fefde02 100644 --- a/lib/class-wp-theme-json-resolver.php +++ b/lib/class-wp-theme-json-resolver.php @@ -233,7 +233,14 @@ private static function get_user_origin() { return $config; } - if ( is_array( $decoded_data ) ) { + // Very important to verify if the flag isGlobalStylesUserThemeJSON is true. + // If is not true the content was not escaped and is not safe. + if ( + is_array( $decoded_data ) && + isset( $decoded_data['isGlobalStylesUserThemeJSON'] ) && + $decoded_data['isGlobalStylesUserThemeJSON'] + ) { + unset( $decoded_data['isGlobalStylesUserThemeJSON'] ); $config = $decoded_data; } } diff --git a/lib/class-wp-theme-json.php b/lib/class-wp-theme-json.php index 6b140156f65db8..8784122040ad79 100644 --- a/lib/class-wp-theme-json.php +++ b/lib/class-wp-theme-json.php @@ -346,6 +346,45 @@ public function __construct( $contexts = array(), $should_escape_styles = false } } + /** + * Returns a mapping on metadata properties to avoid having to constantly + * transforms properties between camel case and kebab. + * + * @return array Containing three mappings + * "to_kebab_case" mapping properties in camel case to + * properties in kebab case e.g: "paddingTop" to "padding-top". + * "to_camel_case" mapping properties in kebab case to + * properties in camel case e.g: "padding-top" to "paddingTop". + * "to_property" mapping properties in kebab case to + * the main properties in camel case e.g: "padding-top" to "padding". + */ + private static function get_properties_metadata_case_mappings() { + static $properties_metadata_case_mappings; + if ( null === $properties_metadata_case_mappings ) { + $properties_metadata_case_mappings = array( + 'to_kebab_case' => array(), + 'to_camel_case' => array(), + 'to_property' => array(), + ); + foreach ( self::PROPERTIES_METADATA as $key => $metadata ) { + $kebab_case = strtolower( preg_replace( '/(? $metadata ) { if ( ! in_array( $name, $context_supports, true ) ) { @@ -696,9 +735,9 @@ private static function compute_style_properties( &$declarations, $context, $con foreach ( $properties as $prop ) { $value = self::get_property_value( $context['styles'], $prop['value'] ); if ( ! empty( $value ) ) { - $kebabcased_name = strtolower( preg_replace( '/(? $kebabcased_name, + 'name' => $kebab_cased_name, 'value' => $value, ); } @@ -1015,6 +1054,120 @@ public function merge( $theme_json ) { } } + /** + * Removes insecure data from theme.json. + */ + public function remove_insecure_properties() { + $blocks_metadata = self::get_blocks_metadata(); + $metadata_mappings = self::get_properties_metadata_case_mappings(); + foreach ( $this->contexts as $context_name => &$context ) { + // Escape the context key; + if ( empty( $blocks_metadata[ $context_name ] ) ) { + unset( $this->contexts[ $context_name ] ); + continue; + } + + $escaped_settings = null; + $escaped_styles = null; + + // Style escaping. + if ( ! empty( $context['styles'] ) ) { + $supports = $blocks_metadata[ $context_name ]['supports']; + $declarations = array(); + self::compute_style_properties( $declarations, $context, $supports ); + foreach ( $declarations as $declaration ) { + $style_to_validate = $declaration['name'] . ': ' . $declaration['value']; + if ( esc_html( safecss_filter_attr( $style_to_validate ) ) === $style_to_validate ) { + if ( null === $escaped_styles ) { + $escaped_styles = array(); + } + $property = $metadata_mappings['to_property'][ $declaration['name'] ]; + $path = self::PROPERTIES_METADATA[ $property ]['value']; + if ( self::has_properties( self::PROPERTIES_METADATA[ $property ] ) ) { + $declaration_divided = explode( '-', $declaration['name'] ); + $path[] = $declaration_divided[1]; + gutenberg_experimental_set( + $escaped_styles, + $path, + gutenberg_experimental_get( $context['styles'], $path ) + ); + } else { + gutenberg_experimental_set( + $escaped_styles, + $path, + gutenberg_experimental_get( $context['styles'], $path ) + ); + } + } + } + } + + // Settings escaping. + // For now the ony allowed settings are presets. + if ( ! empty( $context['settings'] ) ) { + foreach ( self::PRESETS_METADATA as $preset_metadata ) { + $current_preset = gutenberg_experimental_get( $context, $preset_metadata['path'], null ); + if ( null !== $current_preset ) { + $escaped_preset = array(); + foreach ( $current_preset as $single_preset ) { + if ( + esc_attr( esc_html( $single_preset['name'] ) ) === $single_preset['name'] && + sanitize_html_class( $single_preset['slug'] ) === $single_preset['slug'] + ) { + $value = $single_preset[ $preset_metadata['value_key'] ]; + $single_preset_is_valid; + if ( isset( $preset_metadata['classes'] ) && count( $preset_metadata['classes'] ) > 0 ) { + $single_preset_is_valid = true; + foreach ( $preset_metadata['classes'] as $class_meta_data ) { + $property = $class_meta_data['property_name']; + $style_to_validate = $property . ': ' . $value; + if ( esc_html( safecss_filter_attr( $style_to_validate ) ) !== $style_to_validate ) { + $single_preset_is_valid = false; + break; + } + } + } else { + $property = $preset_metadata['css_var_infix']; + $style_to_validate = $property . ': ' . $value; + $single_preset_is_valid = esc_html( safecss_filter_attr( $style_to_validate ) ) === $style_to_validate; + } + if ( $single_preset_is_valid ) { + $escaped_preset[] = $single_preset; + } + } + } + if ( count( $escaped_preset ) > 0 ) { + if ( null === $escaped_settings ) { + $escaped_settings = array(); + } + gutenberg_experimental_set( $escaped_settings, $preset_metadata['path'], $escaped_preset ); + } + } + } + if ( null !== $escaped_settings ) { + $escaped_settings = $escaped_settings['settings']; + } + } + + if ( null === $escaped_settings && null === $escaped_styles ) { + unset( $this->contexts[ $context_name ] ); + } else if ( null !== $escaped_settings && null !== $escaped_styles ) { + $context = array( + 'styles' => $escaped_styles, + 'settings' => $escaped_settings, + ); + } else if ( null === $escaped_settings ) { + $context = array( + 'styles' => $escaped_styles, + ); + } else { + $context = array( + 'settings' => $escaped_settings, + ); + } + } + } + /** * Retuns the raw data. * diff --git a/lib/global-styles.php b/lib/global-styles.php index 800048bdcb219f..7c958bfbb8d693 100644 --- a/lib/global-styles.php +++ b/lib/global-styles.php @@ -271,3 +271,112 @@ function gutenberg_experimental_global_styles_register_user_cpt() { add_action( 'init', 'gutenberg_experimental_global_styles_register_user_cpt' ); add_filter( 'block_editor_settings', 'gutenberg_experimental_global_styles_settings', PHP_INT_MAX ); add_action( 'wp_enqueue_scripts', 'gutenberg_experimental_global_styles_enqueue_assets' ); + + +/** + * Sanitizes global styles user content removing unsafe rules. + * + * @param string $content Post content to filter. + * @return string Filtered post content with unsafe rules removed. + */ +function gutenberg_global_styles_filter_post( $content ) { + $decoded_data = json_decode( stripslashes( $content ), true ); + $json_decoding_error = json_last_error(); + if ( + JSON_ERROR_NONE === $json_decoding_error && + is_array( $decoded_data ) && + isset( $decoded_data['isGlobalStylesUserThemeJSON'] ) && + $decoded_data['isGlobalStylesUserThemeJSON'] + ) { + unset( $decoded_data['isGlobalStylesUserThemeJSON'] ); + $theme_json = new WP_Theme_JSON( $decoded_data ); + $theme_json->remove_insecure_properties(); + $data_to_encode = $theme_json->get_raw_data(); + $data_to_encode['isGlobalStylesUserThemeJSON'] = true; + return wp_json_encode( $data_to_encode ); + } + return $content; +} + +/** + * Adds the filters to filter global styles user theme.json. + */ +function gutenberg_global_styles_kses_init_filters() { + add_filter( 'content_save_pre', 'gutenberg_global_styles_filter_post' ); +} + +/** + * Removes the filters to filter global styles user theme.json. + */ +function gutenberg_global_styles_kses_remove_filters() { + remove_filter( 'content_save_pre', 'gutenberg_global_styles_filter_post' ); +} + +/** + * Register global styles kses filters if the user does not have unfiltered_html capability. + * + * @uses render_block_core_navigation() + * @throws WP_Error An WP_Error exception parsing the block definition. + */ +function gutenberg_global_styles_kses_init() { + gutenberg_global_styles_kses_remove_filters(); + if ( ! current_user_can( 'unfiltered_html' ) || true ) { + gutenberg_global_styles_kses_init_filters(); + } +} + +/** + * This filter is the last being executed on force_filtered_html_on_import. + * If the input of the filter is true it means we are in an import situation and should + * enable kses, independently of the user capabilities. + * So in that case we call gutenberg_global_styles_kses_init_filters; + * + * @param string $arg Input argument of the filter. + * @return string Exactly what was passed as argument. + */ +function gutenberg_global_styles_force_filtered_html_on_import_filter( $arg ) { + // force_filtered_html_on_import is true we need to init the global styles kses filters. + if ( $arg ) { + gutenberg_global_styles_kses_init_filters(); + } + return $arg; +} + +/** + * This filter is the last being executed on force_filtered_html_on_import. + * If the input of the filter is true it means we are in an import situation and should + * enable kses, independently of the user capabilities. + * So in that case we call gutenberg_global_styles_kses_init_filters; + * + * @param bool $allow_css Whether the CSS in the test string is considered safe. + * @param bool $css_test_string The CSS string to test.. + * @return bool If $allow_css is true it returns true. + * If $allow_css is false and the CSS rule is referencing a WordPress css variable it returns true. + * Otherwise the function return false. + */ +function gutenberg_global_styles_include_support_for_wp_variables( $allow_css, $css_test_string ) { + if ( $allow_css ) { + return $allow_css; + } + $allowed_preset_attributes = array( + 'background', + 'background-color', + 'color', + 'font-family', + 'font-size', + ); + $parts = explode( ':', $css_test_string, 2 ); + + if ( ! in_array( trim( $parts[0] ), $allowed_preset_attributes, true ) ) { + return $allow_css; + } + return ! ! preg_match( '/^var\(--wp-[a-zA-Z0-9\-]+\)$/', trim( $parts[1] ) ); +} + + +add_action( 'init', 'gutenberg_global_styles_kses_init' ); +add_action( 'set_current_user', 'gutenberg_global_styles_kses_init' ); +add_filter( 'force_filtered_html_on_import', 'gutenberg_global_styles_force_filtered_html_on_import_filter', 999 ); +add_filter( 'safecss_filter_attr_allow_css', 'gutenberg_global_styles_include_support_for_wp_variables', 10, 2 ); +// This filter needs to be executed last. + diff --git a/packages/edit-site/src/components/editor/global-styles-provider.js b/packages/edit-site/src/components/editor/global-styles-provider.js index 5d6f33118070fa..c24a08964e6de3 100644 --- a/packages/edit-site/src/components/editor/global-styles-provider.js +++ b/packages/edit-site/src/components/editor/global-styles-provider.js @@ -32,7 +32,8 @@ import { } from './utils'; import getGlobalStyles from './global-styles-renderer'; -const EMPTY_CONTENT = '{}'; +const EMPTY_CONTENT = { isGlobalStylesUserThemeJSON: true }; +const EMPTY_CONTENT_STRING = JSON.stringify( EMPTY_CONTENT ); const GlobalStylesContext = createContext( { /* eslint-disable no-unused-vars */ @@ -61,10 +62,10 @@ const useGlobalStylesEntityContent = () => { export const useGlobalStylesReset = () => { const [ content, setContent ] = useGlobalStylesEntityContent(); - const canRestart = !! content && content !== EMPTY_CONTENT; + const canRestart = !! content && content !== EMPTY_CONTENT_STRING; return [ canRestart, - useCallback( () => setContent( EMPTY_CONTENT ), [ setContent ] ), + useCallback( () => setContent( EMPTY_CONTENT_STRING ), [ setContent ] ), ]; }; @@ -135,7 +136,12 @@ export default function GlobalStylesProvider( { children, baseStyles } ) { const contexts = useMemo( () => getContexts( blockTypes ), [ blockTypes ] ); const { userStyles, mergedStyles } = useMemo( () => { - const newUserStyles = content ? JSON.parse( content ) : {}; + let newUserStyles = content ? JSON.parse( content ) : EMPTY_CONTENT; + // It is very important to verify if the flag isGlobalStylesUserThemeJSON is true. + // If it is not true the content was not escaped and is not safe. + if ( ! newUserStyles.isGlobalStylesUserThemeJSON ) { + newUserStyles = EMPTY_CONTENT; + } const newMergedStyles = mergeWith( {}, baseStyles, From eec1df4c188c8fe497f4a4a44382721919f07568 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9?= Date: Wed, 13 Jan 2021 18:32:39 +0100 Subject: [PATCH 2/4] Remove condition used for testing --- lib/global-styles.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/global-styles.php b/lib/global-styles.php index 7c958bfbb8d693..ff1156c6075b98 100644 --- a/lib/global-styles.php +++ b/lib/global-styles.php @@ -320,7 +320,7 @@ function gutenberg_global_styles_kses_remove_filters() { */ function gutenberg_global_styles_kses_init() { gutenberg_global_styles_kses_remove_filters(); - if ( ! current_user_can( 'unfiltered_html' ) || true ) { + if ( ! current_user_can( 'unfiltered_html' ) ) { gutenberg_global_styles_kses_init_filters(); } } From 19b26ed76e4a7758a98d04238e2f85b22a8cc989 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9?= Date: Wed, 13 Jan 2021 18:37:51 +0100 Subject: [PATCH 3/4] Make linter happy --- lib/class-wp-theme-json.php | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/class-wp-theme-json.php b/lib/class-wp-theme-json.php index 8784122040ad79..c8cd12f41023e4 100644 --- a/lib/class-wp-theme-json.php +++ b/lib/class-wp-theme-json.php @@ -709,7 +709,7 @@ private static function compute_style_properties( &$declarations, $context, $con return; } $metadata_mappings = self::get_properties_metadata_case_mappings(); - $properties = array(); + $properties = array(); foreach ( self::PROPERTIES_METADATA as $name => $metadata ) { if ( ! in_array( $name, $context_supports, true ) ) { continue; @@ -736,7 +736,7 @@ private static function compute_style_properties( &$declarations, $context, $con $value = self::get_property_value( $context['styles'], $prop['value'] ); if ( ! empty( $value ) ) { $kebab_cased_name = $metadata_mappings['to_kebab_case'][ $prop['name'] ]; - $declarations[] = array( + $declarations[] = array( 'name' => $kebab_cased_name, 'value' => $value, ); @@ -1061,7 +1061,7 @@ public function remove_insecure_properties() { $blocks_metadata = self::get_blocks_metadata(); $metadata_mappings = self::get_properties_metadata_case_mappings(); foreach ( $this->contexts as $context_name => &$context ) { - // Escape the context key; + // Escape the context key. if ( empty( $blocks_metadata[ $context_name ] ) ) { unset( $this->contexts[ $context_name ] ); continue; @@ -1114,8 +1114,8 @@ public function remove_insecure_properties() { esc_attr( esc_html( $single_preset['name'] ) ) === $single_preset['name'] && sanitize_html_class( $single_preset['slug'] ) === $single_preset['slug'] ) { - $value = $single_preset[ $preset_metadata['value_key'] ]; - $single_preset_is_valid; + $value = $single_preset[ $preset_metadata['value_key'] ]; + $single_preset_is_valid = null; if ( isset( $preset_metadata['classes'] ) && count( $preset_metadata['classes'] ) > 0 ) { $single_preset_is_valid = true; foreach ( $preset_metadata['classes'] as $class_meta_data ) { @@ -1127,8 +1127,8 @@ public function remove_insecure_properties() { } } } else { - $property = $preset_metadata['css_var_infix']; - $style_to_validate = $property . ': ' . $value; + $property = $preset_metadata['css_var_infix']; + $style_to_validate = $property . ': ' . $value; $single_preset_is_valid = esc_html( safecss_filter_attr( $style_to_validate ) ) === $style_to_validate; } if ( $single_preset_is_valid ) { @@ -1151,12 +1151,12 @@ public function remove_insecure_properties() { if ( null === $escaped_settings && null === $escaped_styles ) { unset( $this->contexts[ $context_name ] ); - } else if ( null !== $escaped_settings && null !== $escaped_styles ) { + } elseif ( null !== $escaped_settings && null !== $escaped_styles ) { $context = array( 'styles' => $escaped_styles, 'settings' => $escaped_settings, ); - } else if ( null === $escaped_settings ) { + } elseif ( null === $escaped_settings ) { $context = array( 'styles' => $escaped_styles, ); From 72cc64bd522a845433e675c32a6648a3540f8c40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9?= Date: Wed, 13 Jan 2021 18:38:33 +0100 Subject: [PATCH 4/4] Make linter happy --- lib/global-styles.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/global-styles.php b/lib/global-styles.php index ff1156c6075b98..5aab9277715e46 100644 --- a/lib/global-styles.php +++ b/lib/global-styles.php @@ -291,7 +291,7 @@ function gutenberg_global_styles_filter_post( $content ) { unset( $decoded_data['isGlobalStylesUserThemeJSON'] ); $theme_json = new WP_Theme_JSON( $decoded_data ); $theme_json->remove_insecure_properties(); - $data_to_encode = $theme_json->get_raw_data(); + $data_to_encode = $theme_json->get_raw_data(); $data_to_encode['isGlobalStylesUserThemeJSON'] = true; return wp_json_encode( $data_to_encode ); }