Skip to content

Commit

Permalink
Add: Save time theme.json escaping (#28061)
Browse files Browse the repository at this point in the history
* Add: Save time theme.json escaping.

* Remove condition used for testing

* Make linter happy

* Make linter happy

Co-authored-by: André <[email protected]>
  • Loading branch information
jorgefilipecosta and oandregal authored Jan 13, 2021
1 parent c963e4c commit f27ea4d
Show file tree
Hide file tree
Showing 4 changed files with 285 additions and 10 deletions.
9 changes: 8 additions & 1 deletion lib/class-wp-theme-json-resolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,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;
}
}
Expand Down
163 changes: 158 additions & 5 deletions lib/class-wp-theme-json.php
Original file line number Diff line number Diff line change
Expand Up @@ -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( '/(?<!^)[A-Z]/', '-$0', $key ) );
$properties_metadata_case_mappings['to_kebab_case'][ $key ] = $kebab_case;
$properties_metadata_case_mappings['to_camel_case'][ $kebab_case ] = $key;
$properties_metadata_case_mappings['to_property'][ $kebab_case ] = $key;
if ( self::has_properties( $metadata ) ) {
foreach ( $metadata['properties'] as $property ) {
$camel_case = $key . ucfirst( $property );
$kebab_case = strtolower( preg_replace( '/(?<!^)[A-Z]/', '-$0', $camel_case ) );
$properties_metadata_case_mappings['to_kebab_case'][ $camel_case ] = $kebab_case;
$properties_metadata_case_mappings['to_camel_case'][ $kebab_case ] = $camel_case;
$properties_metadata_case_mappings['to_property'][ $kebab_case ] = $key;
}
}
}
}
return $properties_metadata_case_mappings;
}

/**
* Returns the metadata for each block.
*
Expand Down Expand Up @@ -669,8 +708,8 @@ private static function compute_style_properties( &$declarations, $context, $con
if ( empty( $context['styles'] ) ) {
return;
}

$properties = array();
$metadata_mappings = self::get_properties_metadata_case_mappings();
$properties = array();
foreach ( self::PROPERTIES_METADATA as $name => $metadata ) {
if ( ! in_array( $name, $context_supports, true ) ) {
continue;
Expand All @@ -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( '/(?<!^)[A-Z]/', '-$0', $prop['name'] ) );
$declarations[] = array(
'name' => $kebabcased_name,
$kebab_cased_name = $metadata_mappings['to_kebab_case'][ $prop['name'] ];
$declarations[] = array(
'name' => $kebab_cased_name,
'value' => $value,
);
}
Expand Down Expand Up @@ -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 = null;
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 ] );
} elseif ( null !== $escaped_settings && null !== $escaped_styles ) {
$context = array(
'styles' => $escaped_styles,
'settings' => $escaped_settings,
);
} elseif ( null === $escaped_settings ) {
$context = array(
'styles' => $escaped_styles,
);
} else {
$context = array(
'settings' => $escaped_settings,
);
}
}
}

/**
* Retuns the raw data.
*
Expand Down
109 changes: 109 additions & 0 deletions lib/global-styles.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' ) ) {
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.

14 changes: 10 additions & 4 deletions packages/edit-site/src/components/editor/global-styles-provider.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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 ] ),
];
};

Expand Down Expand Up @@ -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,
Expand Down

0 comments on commit f27ea4d

Please sign in to comment.