You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

899 lines
25 KiB
PHTML

<?php
/**
* Class KSES.
*
* @link https://github.com/googleforcreators/web-stories-wp
*
* @copyright 2020 Google LLC
* @license https://www.apache.org/licenses/LICENSE-2.0 Apache License 2.0
*/
/**
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare(strict_types = 1);
namespace Google\Web_Stories;
use Google\Web_Stories\Infrastructure\HasRequirements;
/**
* KSES class.
*
* Provides KSES utility methods to override the ones from core.
*
* @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
*
* @phpstan-type PostData array{
* post_parent: int|string|null,
* post_type: string,
* post_content?: string,
* post_content_filtered?: string
* }
*/
class KSES extends Service_Base implements HasRequirements {
/**
* Story_Post_Type instance.
*
* @var Story_Post_Type Story_Post_Type instance.
*/
private Story_Post_Type $story_post_type;
/**
* Page_Template_Post_Type instance.
*
* @var Page_Template_Post_Type Page_Template_Post_Type instance.
*/
private Page_Template_Post_Type $page_template_post_type;
/**
* KSES constructor.
*
* @since 1.12.0
*
* @param Story_Post_Type $story_post_type Story_Post_Type instance.
* @param Page_Template_Post_Type $page_template_post_type Page_Template_Post_Type instance.
*/
public function __construct(
Story_Post_Type $story_post_type,
Page_Template_Post_Type $page_template_post_type
) {
$this->story_post_type = $story_post_type;
$this->page_template_post_type = $page_template_post_type;
}
/**
* Initializes KSES filters for stories.
*
* @since 1.0.0
*/
public function register(): void {
add_filter( 'wp_insert_post_data', [ $this, 'filter_insert_post_data' ], 10, 3 );
}
/**
* Get the list of service IDs required for this service to be registered.
*
* Needed because the story post type needs to be registered first.
*
* @since 1.13.0
*
* @return string[] List of required services.
*/
public static function get_requirements(): array {
return [ 'story_post_type', 'page_template_post_type' ];
}
/**
* Filters slashed post data just before it is inserted into the database.
*
* Used to run story HTML markup through KSES on our own, but with some filters applied
* that should only affect the web-story post type.
*
* This allows storing full AMP HTML documents in post_content for stories, which require
* more allowed HTML tags and a patched version of {@see safecss_filter_attr}.
*
* @since 1.8.0
*
* @param mixed $data An array of slashed, sanitized, and processed post data.
* @param mixed $postarr An array of sanitized (and slashed) but otherwise unmodified post data.
* @param mixed $unsanitized_postarr An array of slashed yet *unsanitized* and unprocessed post data as
* originally passed to wp_insert_post().
* @return array<string,mixed>|mixed Filtered post data.
*
* @phpstan-param PostData $data
* @phpstan-param PostData $unsanitized_postarr
*
* @template T
*
* @phpstan-return ($data is array<T> ? array<T> : mixed)
*/
public function filter_insert_post_data( $data, $postarr, $unsanitized_postarr ) {
if ( current_user_can( 'unfiltered_html' ) ) {
return $data;
}
if ( ! \is_array( $data ) || ! \is_array( $postarr ) || ! \is_array( $unsanitized_postarr ) ) {
return $data;
}
if ( ! $this->is_allowed_post_type( $data['post_type'], $data['post_parent'] ) ) {
return $data;
}
if ( isset( $unsanitized_postarr['post_content_filtered'] ) ) {
$data['post_content_filtered'] = $this->filter_story_data( $unsanitized_postarr['post_content_filtered'] );
}
if ( isset( $unsanitized_postarr['post_content'] ) ) {
add_filter( 'safe_style_css', [ $this, 'filter_safe_style_css' ] );
add_filter( 'wp_kses_allowed_html', [ $this, 'filter_kses_allowed_html' ] );
$unsanitized_postarr['post_content'] = $this->filter_content_save_pre_before_kses( $unsanitized_postarr['post_content'] );
$data['post_content'] = wp_filter_post_kses( $unsanitized_postarr['post_content'] );
$data['post_content'] = $this->filter_content_save_pre_after_kses( $data['post_content'] );
remove_filter( 'safe_style_css', [ $this, 'filter_safe_style_css' ] );
remove_filter( 'wp_kses_allowed_html', [ $this, 'filter_kses_allowed_html' ] );
}
return $data;
}
/**
* Filters list of allowed CSS attributes.
*
* @since 1.0.0
*
* @param string[]|mixed $attr Array of allowed CSS attributes.
* @return string[]|mixed Filtered list of CSS attributes.
*
* @template T
*
* @phpstan-return ($attr is array<T> ? array<T> : mixed)
*/
public function filter_safe_style_css( $attr ) {
if ( ! \is_array( $attr ) ) {
return $attr;
}
$additional = [
'display',
'opacity',
'position',
'top',
'left',
'transform',
'white-space',
'clip-path',
'-webkit-clip-path',
'pointer-events',
'will-change',
'--initial-opacity',
'--initial-transform',
];
array_push( $attr, ...$additional );
return $attr;
}
/**
* Filters an inline style attribute and removes disallowed rules.
*
* This is equivalent to the WordPress core function of the same name,
* except that this does not remove CSS with parentheses in it.
*
* A few more allowed attributes are added via the safe_style_css filter.
*
* @SuppressWarnings(PHPMD)
*
* @since 1.0.0
*
* @see safecss_filter_attr()
*
* @param string $css A string of CSS rules.
* @return string Filtered string of CSS rules.
*/
public function safecss_filter_attr( string $css ): string { // phpcs:ignore SlevomatCodingStandard.Complexity.Cognitive.ComplexityTooHigh
$css = wp_kses_no_null( $css );
$css = str_replace( [ "\n", "\r", "\t" ], '', $css );
$allowed_protocols = wp_allowed_protocols();
$css_array = explode( ';', trim( $css ) );
/** This filter is documented in wp-includes/kses.php */
$allowed_attr = apply_filters(
'safe_style_css',
[
'background',
'background-color',
'background-image',
'background-position',
'background-size',
'background-attachment',
'background-blend-mode',
'border',
'border-radius',
'border-width',
'border-color',
'border-style',
'border-right',
'border-right-color',
'border-right-style',
'border-right-width',
'border-bottom',
'border-bottom-color',
'border-bottom-style',
'border-bottom-width',
'border-left',
'border-left-color',
'border-left-style',
'border-left-width',
'border-top',
'border-top-color',
'border-top-style',
'border-top-width',
'border-spacing',
'border-collapse',
'caption-side',
'columns',
'column-count',
'column-fill',
'column-gap',
'column-rule',
'column-span',
'column-width',
'color',
'font',
'font-family',
'font-size',
'font-style',
'font-variant',
'font-weight',
'letter-spacing',
'line-height',
'text-align',
'text-decoration',
'text-indent',
'text-transform',
'height',
'min-height',
'max-height',
'width',
'min-width',
'max-width',
'margin',
'margin-right',
'margin-bottom',
'margin-left',
'margin-top',
'padding',
'padding-right',
'padding-bottom',
'padding-left',
'padding-top',
'flex',
'flex-basis',
'flex-direction',
'flex-flow',
'flex-grow',
'flex-shrink',
'grid-template-columns',
'grid-auto-columns',
'grid-column-start',
'grid-column-end',
'grid-column-gap',
'grid-template-rows',
'grid-auto-rows',
'grid-row-start',
'grid-row-end',
'grid-row-gap',
'grid-gap',
'justify-content',
'justify-items',
'justify-self',
'align-content',
'align-items',
'align-self',
'clear',
'cursor',
'direction',
'float',
'overflow',
'vertical-align',
'list-style-type',
'z-index',
]
);
/*
* CSS attributes that accept URL data types.
*
* This is in accordance to the CSS spec and unrelated to
* the sub-set of supported attributes above.
*
* See: https://developer.mozilla.org/en-US/docs/Web/CSS/url
*/
$css_url_data_types = [
'background',
'background-image',
'cursor',
'list-style',
'list-style-image',
'clip-path',
'-webkit-clip-path',
];
/*
* CSS attributes that accept gradient data types.
*
*/
$css_gradient_data_types = [
'background',
'background-image',
];
/*
* CSS attributes that accept color data types.
*
* This is in accordance to the CSS spec and unrelated to
* the sub-set of supported attributes above.
*
* See: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value
*/
$css_color_data_types = [
'color',
'background',
'background-color',
'border-color',
'box-shadow',
'outline',
'outline-color',
'text-shadow',
];
if ( empty( $allowed_attr ) ) {
return $css;
}
$css = '';
foreach ( $css_array as $css_item ) {
if ( '' === $css_item ) {
continue;
}
$css_item = trim( $css_item );
$css_test_string = $css_item;
$found = false;
$url_attr = false;
$gradient_attr = false;
$color_attr = false;
$transform_attr = false;
$parts = explode( ':', $css_item, 2 );
if ( ! str_contains( $css_item, ':' ) ) {
$found = true;
} else {
$css_selector = trim( $parts[0] );
if ( \in_array( $css_selector, $allowed_attr, true ) ) {
$found = true;
$url_attr = \in_array( $css_selector, $css_url_data_types, true );
$gradient_attr = \in_array( $css_selector, $css_gradient_data_types, true );
$color_attr = \in_array( $css_selector, $css_color_data_types, true );
// --initial-transform is a special custom property used by the story editor.
$transform_attr = 'transform' === $css_selector || '--initial-transform' === $css_selector;
}
}
if ( $found && $url_attr ) {
$url_matches = [];
// Simplified: matches the sequence `url(*)`.
preg_match_all( '/url\([^)]+\)/', $parts[1], $url_matches );
foreach ( $url_matches[0] as $url_match ) {
$url_pieces = [];
// Clean up the URL from each of the matches above.
preg_match( '/^url\(\s*([\'\"]?)(.*)(\g1)\s*\)$/', $url_match, $url_pieces );
if ( empty( $url_pieces[2] ) ) {
$found = false;
break;
}
$url = trim( $url_pieces[2] );
if ( empty( $url ) || wp_kses_bad_protocol( $url, $allowed_protocols ) !== $url ) {
$found = false;
break;
}
// Remove the whole `url(*)` bit that was matched above from the CSS.
$css_test_string = str_replace( $url_match, '', $css_test_string );
}
}
if ( $found && $gradient_attr ) {
$css_value = trim( $parts[1] );
if ( preg_match( '/^(repeating-)?(linear|radial|conic)-gradient\(([^()]|rgb[a]?\([^()]*\))*\)$/', $css_value ) ) {
// Remove the whole `gradient` bit that was matched above from the CSS.
$css_test_string = str_replace( $css_value, '', $css_test_string );
}
}
if ( $found && $color_attr ) {
$color_matches = [];
// Simplified: matches the sequence `rgb(*)` and `rgba(*)`.
preg_match_all( '/rgba?\([^)]+\)/', $parts[1], $color_matches );
foreach ( $color_matches[0] as $color_match ) {
$color_pieces = [];
// Clean up the color from each of the matches above.
preg_match( '/^rgba?\([^)]*\)$/', $color_match, $color_pieces );
// Remove the whole `rgb(*)` / `rgba(*) bit that was matched above from the CSS.
$css_test_string = str_replace( $color_match, '', $css_test_string );
}
}
if ( $found && $transform_attr ) {
$css_value = trim( $parts[1] );
if ( preg_match( '/^((matrix|matrix3d|perspective|rotate|rotate3d|rotateX|rotateY|rotateZ|translate|translate3d|translateX|translatY|translatZ|scale|scale3d|scalX|scaleY|scaleZ|skew|skewX|skeY)\(([^()])*\) ?)+$/', $css_value ) ) {
// Remove the whole `gradient` bit that was matched above from the CSS.
$css_test_string = str_replace( $css_value, '', $css_test_string );
}
}
if ( $found ) {
// Allow CSS calc().
$css_test_string = (string) preg_replace( '/calc\(((?:\([^()]*\)?|[^()])*)\)/', '', $css_test_string );
// Allow CSS var().
$css_test_string = (string) preg_replace( '/\(?var\(--[a-zA-Z0-9_-]*\)/', '', $css_test_string );
// Check for any CSS containing \ ( & } = or comments,
// except for url(), calc(), or var() usage checked above.
$allow_css = ! preg_match( '%[\\\(&=}]|/\*%', $css_test_string );
/** This filter is documented in wp-includes/kses.php */
$allow_css = apply_filters( 'safecss_filter_attr_allow_css', $allow_css, $css_test_string );
// Only add the CSS part if it passes the regex check.
if ( $allow_css ) {
if ( '' !== $css ) {
$css .= ';';
}
$css .= $css_item;
}
}
}
return $css;
}
/**
* Filter the allowed tags for KSES to allow for complete amp-story document markup.
*
* @SuppressWarnings(PHPMD.ExcessiveMethodLength)
*
* @since 1.0.0
*
* @param array<string, array<string,bool>>|mixed $allowed_tags Allowed tags.
* @return array<string, array<string,bool>>|mixed Allowed tags.
*
* @template T
*
* @phpstan-return ($allowed_tags is array<T> ? array<T> : mixed)
*/
public function filter_kses_allowed_html( $allowed_tags ) {
if ( ! \is_array( $allowed_tags ) ) {
return $allowed_tags;
}
$story_components = [
'html' => [
'amp' => true,
'lang' => true,
],
'head' => [],
'body' => [],
'meta' => [
'name' => true,
'content' => true,
'charset' => true,
],
'script' => [
'async' => true,
'src' => true,
'custom-element' => true,
'type' => true,
],
'noscript' => [],
'link' => [
'href' => true,
'rel' => true,
],
'style' => [
'type' => true,
'amp-boilerplate' => true,
'amp-custom' => true,
],
'amp-story' => [
'background-audio' => true,
'live-story' => true,
'live-story-disabled' => true,
'poster-landscape-src' => true,
'poster-portrait-src' => true,
'poster-square-src' => true,
'publisher' => true,
'publisher-logo-src' => true,
'standalone' => true,
'supports-landscape' => true,
'title' => true,
],
'amp-story-captions' => [
'height' => true,
'style-preset' => true,
],
'amp-story-shopping-attachment' => [
'cta-text' => true,
'theme' => true,
'src' => true,
],
'amp-story-shopping-config' => [
'src' => true,
],
'amp-story-shopping-tag' => [],
'amp-story-page' => [
'auto-advance-after' => true,
'background-audio' => true,
'id' => true,
],
'amp-story-page-attachment' => [
'href' => true,
'theme' => true,
],
'amp-story-page-outlink' => [
'cta-image' => true,
'theme' => true,
'cta-accent-color' => true,
'cta-accent-element' => true,
],
'amp-story-grid-layer' => [
'aspect-ratio' => true,
'position' => true,
'template' => true,
],
'amp-story-cta-layer' => [],
'amp-story-animation' => [
'trigger' => true,
],
'amp-img' => [
'alt' => true,
'attribution' => true,
'data-amp-bind-alt' => true,
'data-amp-bind-attribution' => true,
'data-amp-bind-src' => true,
'data-amp-bind-srcset' => true,
'disable-inline-width' => true,
'lightbox' => true,
'lightbox-thumbnail-id' => true,
'media' => true,
'noloading' => true,
'object-fit' => true,
'object-position' => true,
'placeholder' => true,
'sizes' => true,
'src' => true,
'srcset' => true,
],
'amp-video' => [
'album' => true,
'alt' => true,
'artist' => true,
'artwork' => true,
'attribution' => true,
'autoplay' => true,
'captions-id' => true,
'controls' => true,
'controlslist' => true,
'crossorigin' => true,
'data-amp-bind-album' => true,
'data-amp-bind-alt' => true,
'data-amp-bind-artist' => true,
'data-amp-bind-artwork' => true,
'data-amp-bind-attribution' => true,
'data-amp-bind-controls' => true,
'data-amp-bind-controlslist' => true,
'data-amp-bind-loop' => true,
'data-amp-bind-poster' => true,
'data-amp-bind-preload' => true,
'data-amp-bind-src' => true,
'data-amp-bind-title' => true,
'disableremoteplayback' => true,
'dock' => true,
'lightbox' => true,
'lightbox-thumbnail-id' => true,
'loop' => true,
'media' => true,
'muted' => true,
'noaudio' => true,
'noloading' => true,
'object-fit' => true,
'object-position' => true,
'placeholder' => true,
'poster' => true,
'preload' => true,
'rotate-to-fullscreen' => true,
'src' => true,
],
'source' => [
'type' => true,
'src' => true,
],
'img' => [
'alt' => true,
'attribution' => true,
'border' => true,
'decoding' => true,
'height' => true,
'importance' => true,
'intrinsicsize' => true,
'ismap' => true,
'loading' => true,
'longdesc' => true,
'sizes' => true,
'src' => true,
'srcset' => true,
'srcwidth' => true,
'width' => true,
],
'svg' => [
'width' => true,
'height' => true,
'viewbox' => true,
'fill' => true,
'xmlns' => true,
],
'clippath' => [
'transform' => true,
'clippathunits' => true,
'path' => true,
],
'defs' => [],
'feblend' => [
'in' => true,
'in2' => true,
'result' => true,
],
'fecolormatrix' => [
'in' => true,
'values' => true,
],
'feflood' => [
'flood-opacity' => true,
'result' => true,
],
'fegaussianblur' => [
'stddeviation' => true,
],
'feoffset' => [],
'filter' => [
'id' => true,
'x' => true,
'y' => true,
'width' => true,
'height' => true,
'filterunits' => true,
'color-interpolation-filters' => true,
],
'g' => [
'filter' => true,
'opacity' => true,
],
'path' => [
'd' => true,
'fill-rule' => true,
'clip-rule' => true,
'fill' => true,
],
];
$allowed_tags = $this->array_merge_recursive_distinct( $allowed_tags, $story_components );
$allowed_tags = array_map( [ $this, 'add_global_attributes' ], $allowed_tags );
return $allowed_tags;
}
/**
* Temporarily renames the style attribute to data-temp-style in full story markup.
*
* @since 1.0.0
*
* @param string $post_content Post content.
* @return string Filtered post content.
*/
public function filter_content_save_pre_before_kses( string $post_content ): string {
return (string) preg_replace_callback(
'|(?P<before><\w+(?:-\w+)*\s[^>]*?)style=\\\"(?P<styles>[^"]*)\\\"(?P<after>([^>]+?)*>)|', // Extra slashes appear here because $post_content is pre-slashed..
static fn( $matches ) => $matches['before'] . sprintf( ' data-temp-style="%s" ', $matches['styles'] ) . $matches['after'],
$post_content
);
}
/**
* Renames data-temp-style back to style in full story markup.
*
* @since 1.0.0
*
* @param string $post_content Post content.
* @return string Filtered post content.
*/
public function filter_content_save_pre_after_kses( string $post_content ): string {
return (string) preg_replace_callback(
'/ data-temp-style=\\\"(?P<styles>[^"]*)\\\"/',
function ( $matches ) {
$styles = str_replace( '&quot;', '\"', $matches['styles'] );
return sprintf( ' style="%s"', esc_attr( $this->safecss_filter_attr( wp_kses_stripslashes( $styles ) ) ) );
},
$post_content
);
}
/**
* Checks whether the post type is correct and user has capability to edit it.
*
* @since 1.22.0
*
* @param string $post_type Post type slug.
* @param int|string|null $post_parent Parent post ID.
* @return bool Whether the user can edit the provided post type.
*/
private function is_allowed_post_type( string $post_type, $post_parent ): bool {
if ( $this->story_post_type->get_slug() === $post_type && $this->story_post_type->has_cap( 'edit_posts' ) ) {
return true;
}
if ( $this->page_template_post_type->get_slug() === $post_type && $this->page_template_post_type->has_cap( 'edit_posts' ) ) {
return true;
}
// For story autosaves.
if (
(
'revision' === $post_type &&
! empty( $post_parent ) &&
get_post_type( (int) $post_parent ) === $this->story_post_type->get_slug()
) &&
$this->story_post_type->has_cap( 'edit_posts' )
) {
return true;
}
return false;
}
/**
* Filters story data.
*
* Provides simple sanity check to ensure story data is valid JSON.
*
* @since 1.22.0
*
* @param string $story_data JSON-encoded story data.
* @return string Sanitized & slashed story data.
*/
private function filter_story_data( string $story_data ): string {
$decoded = json_decode( (string) wp_unslash( $story_data ), true );
return null === $decoded ? '' : wp_slash( (string) wp_json_encode( $decoded ) );
}
/**
* Recursively merge multiple arrays and ensure values are distinct.
*
* Based on information found in http://www.php.net/manual/en/function.array-merge-recursive.php
*
* @since 1.5.0
*
* @param array<int|string,mixed> ...$arrays [optional] Variable list of arrays to recursively merge.
* @return array<int|string,mixed> An array of values resulted from merging the arguments together.
*/
protected function array_merge_recursive_distinct( array ...$arrays ): array {
if ( \count( $arrays ) < 2 ) {
if ( [] === $arrays ) {
return $arrays;
}
return array_shift( $arrays );
}
$merged = array_shift( $arrays );
foreach ( $arrays as $array ) {
foreach ( $array as $key => $value ) {
if ( \is_array( $value ) && ( isset( $merged[ $key ] ) && \is_array( $merged[ $key ] ) ) ) {
$merged[ $key ] = $this->array_merge_recursive_distinct( $merged[ $key ], $value );
} else {
$merged[ $key ] = $value;
}
}
}
return $merged;
}
/**
* Helper function to add global attributes to a tag in the allowed HTML list.
*
* @since 1.0.0
*
* @see _wp_add_global_attributes
*
* @param array<string,bool> $value An array of attributes.
* @return array<string,bool> The array of attributes with global attributes added.
*/
protected function add_global_attributes( array $value ): array {
$global_attributes = [
'aria-describedby' => true,
'aria-details' => true,
'aria-label' => true,
'aria-labelledby' => true,
'aria-hidden' => true,
'class' => true,
'id' => true,
'style' => true,
'title' => true,
'role' => true,
'data-*' => true,
'animate-in' => true,
'animate-in-duration' => true,
'animate-in-delay' => true,
'animate-in-after' => true,
'animate-in-layout' => true,
'layout' => true,
];
return array_merge( $value, $global_attributes );
}
}