Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add: Save time theme.json escaping #28061

Merged
merged 4 commits into from
Jan 13, 2021
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -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;
}
}
Expand Down
159 changes: 156 additions & 3 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,7 +708,7 @@ private static function compute_style_properties( &$declarations, $context, $con
if ( empty( $context['styles'] ) ) {
return;
}

$metadata_mappings = self::get_properties_metadata_case_mappings();
$properties = array();
foreach ( self::PROPERTIES_METADATA as $name => $metadata ) {
if ( ! in_array( $name, $context_supports, true ) ) {
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'] ) );
$kebab_cased_name = $metadata_mappings['to_kebab_case'][ $prop['name'] ];
$declarations[] = array(
'name' => $kebabcased_name,
'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;
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.
*
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' ) || true ) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This if condition is always true.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @Soean, that was done on purpose to make it easier to test the PR, without needing a multisite where admin doesn't have an unfiltered HTML capability, or without using a plugin that removes the capability. Before merging the PR the "|| true" should be removed.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps we can remove the true condition from this code and add it to the testing instructions? We need to test both that users can and can't store that data depending on their capabilities.

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