From 18582ce4ba08d4dbfdb2199d1c3d632ad6bbe2d1 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Fri, 7 Jun 2024 10:54:12 +0200 Subject: [PATCH] Run full AMP sanitization instead of KSES upon save --- includes/KSES.php | 727 +----------------- .../data/story_post_content_sanitized.html | 135 ++++ tests/phpunit/integration/tests/KSES.php | 497 ------------ .../REST_API/Stories_Autosaves_Controller.php | 3 +- .../tests/REST_API/Stories_Controller.php | 7 +- 5 files changed, 178 insertions(+), 1191 deletions(-) create mode 100644 tests/phpunit/integration/data/story_post_content_sanitized.html delete mode 100644 tests/phpunit/integration/tests/KSES.php diff --git a/includes/KSES.php b/includes/KSES.php index 4c1b3821ed42..340999950a40 100644 --- a/includes/KSES.php +++ b/includes/KSES.php @@ -28,7 +28,12 @@ namespace Google\Web_Stories; +use Google\Web_Stories\AMP\Meta_Sanitizer; +use Google\Web_Stories\AMP\Tag_And_Attribute_Sanitizer; use Google\Web_Stories\Infrastructure\HasRequirements; +use Google\Web_Stories_Dependencies\AMP_Content_Sanitizer; +use Google\Web_Stories_Dependencies\AMP_Script_Sanitizer; +use Google\Web_Stories_Dependencies\AmpProject\Dom\Document; /** * KSES class. @@ -101,11 +106,10 @@ public static function get_requirements(): array { /** * 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. + * Used to run story HTML markup through full AMP sanitization instead of just KSES. * - * 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}. + * This allows storing full, valid AMP HTML documents in post_content for stories, which require + * more allowed HTML tags. * * @since 1.8.0 * @@ -140,649 +144,58 @@ public function filter_insert_post_data( $data, $postarr, $unsanitized_postarr ) } 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' ] ); + $data['post_content'] = wp_slash( $this->sanitize_content( wp_unslash( $unsanitized_postarr['post_content'] ) ) ); } return $data; } /** - * Filters list of allowed CSS attributes. - * - * @since 1.0.0 + * Sanitizes post content. * - * @param string[]|mixed $attr Array of allowed CSS attributes. - * @return string[]|mixed Filtered list of CSS attributes. + * @since 1.37.0 * - * @template T - * - * @phpstan-return ($attr is array ? array : mixed) + * @param string $content Unsanitized post content. */ - 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; - } - } + private function sanitize_content( string $content ): string { + $dom = Document::fromHtml( $content ); + if ( $dom instanceof Document ) { + $sanitizers = $this->get_sanitizers(); + AMP_Content_Sanitizer::sanitize_document( $dom, $sanitizers, [] ); + return trim( $dom->saveHTML() ); } - return $css; + return ''; } /** - * Filter the allowed tags for KSES to allow for complete amp-story document markup. + * Returns a list of sanitizers to use. * - * @SuppressWarnings(PHPMD.ExcessiveMethodLength) - * - * @since 1.0.0 + * This is replica of the Sanitization class implementation + * to have a minimal AMP sanitization for user-provided input. * - * @param array>|mixed $allowed_tags Allowed tags. - * @return array>|mixed Allowed tags. + * @since 1.37.0 * - * @template T + * @see Sanitization * - * @phpstan-return ($allowed_tags is array ? array : mixed) + * @return array> Sanitizers. */ - 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, - ], - 'amp-story-audio-sticker' => [ - 'size' => true, - 'sticker' => true, - 'sticker-style' => true, + private function get_sanitizers(): array { + $sanitizers = [ + AMP_Script_Sanitizer::class => [ + 'sanitize_js_scripts' => true, ], + Meta_Sanitizer::class => [], + Tag_And_Attribute_Sanitizer::class => [], ]; - $allowed_tags = $this->array_merge_recursive_distinct( $allowed_tags, $story_components ); - - $allowed_tags = array_map( [ $this, 'add_global_attributes' ], $allowed_tags ); - - return $allowed_tags; - } + foreach ( $sanitizers as &$sanitizer ) { + $sanitizer['validation_error_callback'] = static fn( array $error, array $data = [] ): bool => apply_filters( 'web_stories_amp_validation_error_sanitized', true, $error ); // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + } - /** - * 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<\w+(?:-\w+)*\s[^>]*?)style=\\\"(?P[^"]*)\\\"(?P([^>]+?)*>)|', // 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 - ); - } + unset( $sanitizer ); - /** - * 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[^"]*)\\\"/', - function ( $matches ) { - $styles = str_replace( '"', '\"', $matches['styles'] ); - return sprintf( ' style="%s"', esc_attr( $this->safecss_filter_attr( wp_kses_stripslashes( $styles ) ) ) ); - }, - $post_content - ); + return $sanitizers; } /** @@ -832,72 +245,4 @@ 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 ...$arrays [optional] Variable list of arrays to recursively merge. - * @return array 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 $value An array of attributes. - * @return array 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 ); - } } diff --git a/tests/phpunit/integration/data/story_post_content_sanitized.html b/tests/phpunit/integration/data/story_post_content_sanitized.html new file mode 100644 index 000000000000..100397919334 --- /dev/null +++ b/tests/phpunit/integration/data/story_post_content_sanitized.html @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+
+ +
+
+

+ Fill in some text

+
+
+
+
+
+ + diff --git a/tests/phpunit/integration/tests/KSES.php b/tests/phpunit/integration/tests/KSES.php deleted file mode 100644 index a67f83f4897b..000000000000 --- a/tests/phpunit/integration/tests/KSES.php +++ /dev/null @@ -1,497 +0,0 @@ -instance = $this->injector->make( \Google\Web_Stories\KSES::class ); - } - /** - * Testing the safecss_filter_attr() function. - * - * @param string $css A string of CSS rules. - * @param string $expected Expected string of CSS rules. - * - * @dataProvider data_test_safecss_filter_attr - * @covers ::safecss_filter_attr - */ - public function test_safecss_filter_attr( string $css, string $expected ): void { - $this->assertSame( $expected, $this->instance->safecss_filter_attr( $css ) ); - } - - /** - * Testing the safecss_filter_attr() function with transform attributes. - * - * @param string $css A string of CSS rules. - * @param string $expected Expected string of CSS rules. - * - * @dataProvider data_test_safecss_filter_attr - * @dataProvider data_test_safecss_filter_attr_extended - * @covers ::safecss_filter_attr - */ - public function test_safecss_filter_attr_extended( string $css, string $expected ): void { - add_filter( 'safe_style_css', [ $this->instance, 'filter_safe_style_css' ] ); - $actual = $this->instance->safecss_filter_attr( $css ); - remove_filter( 'safe_style_css', [ $this->instance, 'filter_safe_style_css' ] ); - - $this->assertSame( $expected, $actual ); - } - - /** - * Tests if two arrays are recursively merged, the latter overwriting the first. - * - * @covers ::array_merge_recursive_distinct - */ - public function test_array_merge_recursive_distinct(): void { - $input_array1 = [ - 'one' => [ - 'one-one' => [], - ], - ]; - - $input_array2 = [ - 'one' => [ - 'one-one' => 'string', - ], - ]; - - $output = $this->call_private_method( - [ $this->instance, 'array_merge_recursive_distinct' ], - [ - $input_array1, - $input_array2, - ] - ); - $this->assertEquals( $output['one']['one-one'], 'string' ); - } - - /** - * Data Provider for test_safecss_filter_attr(). - * - * @return array { - * @type array { - * @string string $css A string of CSS rules. - * @string string $expected Expected string of CSS rules. - * } - * } - * - * @phpstan-return array - */ - public function data_test_safecss_filter_attr(): array { - return [ - // Empty input, empty output. - [ - 'css' => '', - 'expected' => '', - ], - // An arbitrary attribute name isn't allowed. - [ - 'css' => 'foo:bar', - 'expected' => '', - ], - // A single attribute name, with a single value. - [ - 'css' => 'margin-top: 2px', - 'expected' => 'margin-top: 2px', - ], - // Backslash \ isn't supported. - [ - 'css' => 'margin-top: \2px', - 'expected' => '', - ], - // Curly bracket } isn't supported. - [ - 'css' => 'margin-bottom: 2px}', - 'expected' => '', - ], - // A single attribute name, with a single text value. - [ - 'css' => 'text-transform: uppercase', - 'expected' => 'text-transform: uppercase', - ], - // Only lowercase attribute names are supported. - [ - 'css' => 'Text-transform: capitalize', - 'expected' => '', - ], - // Uppercase attribute values goes through. - [ - 'css' => 'text-transform: None', - 'expected' => 'text-transform: None', - ], - // A single attribute, with multiple values. - [ - 'css' => 'font: bold 15px arial, sans-serif', - 'expected' => 'font: bold 15px arial, sans-serif', - ], - // Multiple attributes, with single values. - [ - 'css' => 'font-weight: bold;font-size: 15px', - 'expected' => 'font-weight: bold;font-size: 15px', - ], - // Multiple attributes, separated by a space. - [ - 'css' => 'font-weight: bold; font-size: 15px', - 'expected' => 'font-weight: bold;font-size: 15px', - ], - // Multiple attributes, with multiple values. - [ - 'css' => 'margin: 10px 20px;padding: 5px 10px', - 'expected' => 'margin: 10px 20px;padding: 5px 10px', - ], - // Parenthesis ( is supported for some attributes. - [ - 'css' => 'background: green url("foo.jpg") no-repeat fixed center', - 'expected' => 'background: green url("foo.jpg") no-repeat fixed center', - ], - // Additional background attributes introduced in 5.3. - [ - 'css' => 'background-size: cover;background-size: 200px 100px;background-attachment: local, scroll;background-blend-mode: hard-light', - 'expected' => 'background-size: cover;background-size: 200px 100px;background-attachment: local, scroll;background-blend-mode: hard-light', - ], - // `border-radius` attribute introduced in 5.3. - [ - 'css' => 'border-radius: 10% 30% 50% 70%;border-radius: 30px', - 'expected' => 'border-radius: 10% 30% 50% 70%;border-radius: 30px', - ], - // `flex` and related attributes introduced in 5.3. - [ - 'css' => 'flex: 0 1 auto;flex-basis: 75%;flex-direction: row-reverse;flex-flow: row-reverse nowrap;flex-grow: 2;flex-shrink: 1', - 'expected' => 'flex: 0 1 auto;flex-basis: 75%;flex-direction: row-reverse;flex-flow: row-reverse nowrap;flex-grow: 2;flex-shrink: 1', - ], - // `grid` and related attributes introduced in 5.3. - [ - 'css' => 'grid-template-columns: 1fr 60px;grid-auto-columns: min-content;grid-column-start: span 2;grid-column-end: -1;grid-column-gap: 10%;grid-gap: 10px 20px', - 'expected' => 'grid-template-columns: 1fr 60px;grid-auto-columns: min-content;grid-column-start: span 2;grid-column-end: -1;grid-column-gap: 10%;grid-gap: 10px 20px', - ], - [ - 'css' => 'grid-template-rows: 40px 4em 40px;grid-auto-rows: min-content;grid-row-start: -1;grid-row-end: 3;grid-row-gap: 1em', - 'expected' => 'grid-template-rows: 40px 4em 40px;grid-auto-rows: min-content;grid-row-start: -1;grid-row-end: 3;grid-row-gap: 1em', - ], - // `grid` does not yet support functions or `\`. - [ - 'css' => 'grid-template-columns: repeat(2, 50px 1fr);grid-template: 1em / 20% 20px 1fr', - 'expected' => '', - ], - // `flex` and `grid` alignments introduced in 5.3. - [ - 'css' => 'align-content: space-between;align-items: start;align-self: center;justify-items: center;justify-content: space-between;justify-self: end', - 'expected' => 'align-content: space-between;align-items: start;align-self: center;justify-items: center;justify-content: space-between;justify-self: end', - ], - // `columns` and related attributes introduced in 5.3. - [ - 'css' => 'columns: 6rem auto;column-count: 4;column-fill: balance;column-gap: 9px;column-rule: thick inset blue;column-span: none;column-width: 120px', - 'expected' => 'columns: 6rem auto;column-count: 4;column-fill: balance;column-gap: 9px;column-rule: thick inset blue;column-span: none;column-width: 120px', - ], - // Gradients introduced in 5.3. - [ - 'css' => 'background: linear-gradient(135deg,rgba(6,147,227,1) 0%,rgb(155,81,224) 100%)', - 'expected' => 'background: linear-gradient(135deg,rgba(6,147,227,1) 0%,rgb(155,81,224) 100%)', - ], - [ - 'css' => 'background: linear-gradient(135deg,rgba(6,147,227,1) ) (0%,rgb(155,81,224) 100%)', - 'expected' => '', - ], - [ - 'css' => 'background-image: linear-gradient(red,yellow);', - 'expected' => 'background-image: linear-gradient(red,yellow)', - ], - [ - 'css' => 'color: linear-gradient(red,yellow);', - 'expected' => '', - ], - [ - 'css' => 'background-image: linear-gradient(red,yellow); background: prop( red,yellow); width: 100px;', - 'expected' => 'background-image: linear-gradient(red,yellow);width: 100px', - ], - [ - 'css' => 'background: unknown-gradient(135deg,rgba(6,147,227,1) 0%,rgb(155,81,224) 100%)', - 'expected' => '', - ], - [ - 'css' => 'background: repeating-linear-gradient(135deg,rgba(6,147,227,1) 0%,rgb(155,81,224) 100%)', - 'expected' => 'background: repeating-linear-gradient(135deg,rgba(6,147,227,1) 0%,rgb(155,81,224) 100%)', - ], - [ - 'css' => 'width: 100px; height: 100px; background: linear-gradient(135deg,rgba(0,208,132,1) 0%,rgba(6,147,227,1) 100%);', - 'expected' => 'width: 100px;height: 100px;background: linear-gradient(135deg,rgba(0,208,132,1) 0%,rgba(6,147,227,1) 100%)', - ], - [ - 'css' => 'background: radial-gradient(#ff0, red, yellow, green, rgba(6,147,227,1), rgb(155,81,224) 90%);', - 'expected' => 'background: radial-gradient(#ff0, red, yellow, green, rgba(6,147,227,1), rgb(155,81,224) 90%)', - ], - [ - 'css' => 'background: radial-gradient(#ff0, red, yellow, green, rgba(6,147,227,1), rgb(155,81,224) 90%);', - 'expected' => 'background: radial-gradient(#ff0, red, yellow, green, rgba(6,147,227,1), rgb(155,81,224) 90%)', - ], - [ - 'css' => 'background: conic-gradient(at 0% 30%, red 10%, yellow 30%, #1e90ff 50%)', - 'expected' => 'background: conic-gradient(at 0% 30%, red 10%, yellow 30%, #1e90ff 50%)', - ], - // Expressions are not allowed. - [ - 'css' => 'height: expression( body.scrollTop + 50 + "px" )', - 'expected' => '', - ], - // Other than in core, RGB color values ARE allowed. - [ - 'css' => 'color: rgb( 100, 100, 100 )', - 'expected' => 'color: rgb( 100, 100, 100 )', - ], - // Other than in core, RGBA color values ARE allowed. - [ - 'css' => 'color: rgb( 100, 100, 100, .4 )', - 'expected' => 'color: rgb( 100, 100, 100, .4 )', - ], - ]; - } - - /** - * Data Provider for test_safecss_filter_attr_extended(). - * - * @return array { - * @type array { - * @string string $css A string of CSS rules. - * @string string $expected Expected string of CSS rules. - * } - * } - * - * @phpstan-return array - */ - public function data_test_safecss_filter_attr_extended(): array { - return [ - // Keyword values. - [ - 'css' => 'transform: none;', - 'expected' => 'transform: none', - ], - // Function values. - [ - 'css' => 'transform: rotate(90deg);', - 'expected' => 'transform: rotate(90deg)', - ], - // Multiple function values. - [ - 'css' => 'transform: perspective(500px) translate(10px, 0, 20px) rotateY(3deg);', - 'expected' => 'transform: perspective(500px) translate(10px, 0, 20px) rotateY(3deg)', - ], - [ - 'css' => '--initial-opacity: 1;', - 'expected' => '--initial-opacity: 1', - ], - [ - 'css' => '--initial-transform: rotate(4deg) translate3d(108.30768%, 0px, 0) rotate(-4deg);', - 'expected' => '--initial-transform: rotate(4deg) translate3d(108.30768%, 0px, 0) rotate(-4deg)', - ], - // Global values. - [ - 'css' => 'transform: inherit;', - 'expected' => 'transform: inherit', - ], - // Multiple fonts. - [ - 'css' => 'font-family: "Roboto", "Helvetica Neue", "Helvetica", sans-serif', - 'expected' => 'font-family: "Roboto", "Helvetica Neue", "Helvetica", sans-serif', - ], - // RGBA Background color. - [ - 'css' => 'background-color:rgba(255,255,255,0.6);', - 'expected' => 'background-color:rgba(255,255,255,0.6)', - ], - // CSS clip paths. - [ - 'css' => 'clip-path:url(#mask-circle-foo-bar)', - 'expected' => 'clip-path:url(#mask-circle-foo-bar)', - ], - [ - 'css' => '-webkit-clip-path:url(#mask-circle-foo-bar)', - 'expected' => '-webkit-clip-path:url(#mask-circle-foo-bar)', - ], - // Pointer events. - [ - 'css' => 'pointer-events: initial', - 'expected' => 'pointer-events: initial', - ], - // See https://github.com/googleforcreators/web-stories-wp/pull/7380. - [ - 'css' => 'will-change: transform', - 'expected' => 'will-change: transform', - ], - // CSS calc(). - [ - 'width: calc(2em + 3px)', - 'width: calc(2em + 3px)', - ], - // CSS variable. - [ - 'padding: var(--wp-var1) var(--wp-var2)', - 'padding: var(--wp-var1) var(--wp-var2)', - ], - // CSS calc() with var() custom property. - [ - 'margin-top: calc(var(--wp-var1) * 3 + 2em)', - 'margin-top: calc(var(--wp-var1) * 3 + 2em)', - ], - // Malformed calc, no closing `)`. - [ - 'width: calc(3em + 10px', - '', - ], - // Malformed var, no closing `)`. - [ - 'width: var(--wp-var1', - '', - ], - ]; - } - - /** - * Testing the filter_kses_allowed_html() method. - * - * @param string $html HTML string. - * @param string $expected Expected output. - * - * @dataProvider data_test_filter_kses_allowed_html - * @covers ::filter_kses_allowed_html - * @covers ::add_global_attributes - * @covers ::array_merge_recursive_distinct - */ - public function test_filter_kses_allowed_html( string $html, string $expected ): void { - add_filter( 'wp_kses_allowed_html', [ $this->instance, 'filter_kses_allowed_html' ] ); - - $this->assertSame( $expected, wp_unslash( wp_filter_post_kses( $html ) ) ); - remove_filter( 'wp_kses_allowed_html', [ $this->instance, 'filter_kses_allowed_html' ] ); - } - - /** - * Testing the filter_kses_allowed_html() method. - * - * @covers ::filter_kses_allowed_html - * @covers ::array_merge_recursive_distinct - */ - public function test_filter_kses_allowed_html_uses_deep_merge(): void { - $allowed_tags = [ - 'img' => [ - 'width' => true, - ], - 'testing' => [ - 'width' => true, - ], - ]; - - $result = $this->instance->filter_kses_allowed_html( $allowed_tags ); - - $this->assertArrayHasKey( 'img', $result ); - $this->assertArrayHasKey( 'width', $result['img'] ); - $this->assertArrayHasKey( 'intrinsicsize', $result['img'] ); - $this->assertArrayHasKey( 'testing', $result ); - $this->assertArrayHasKey( 'width', $result['testing'] ); - } - - /** - * @return array - */ - public function data_test_filter_kses_allowed_html(): array { - $blue_rings_svg = (string) file_get_contents( WEB_STORIES_TEST_DATA_DIR . '/multipleBlueRings.svg' ); - - return [ - 'Video Element' => [ - '', - '', - ], - 'Masking' => [ - '', - '', - ], - 'ARIA Roles' => [ - '
', - '
', - ], - 'Global Attributes' => [ - '
', - '
', - ], - 'Img Attributes' => [ - 'Example', - 'Example', - ], - 'Data Attributes' => [ - '', - '', - ], - 'AMP Layout' => [ - '', - '', - ], - 'AMP Animations' => [ - '

Hello World

Hello World

', - '

Hello World

Hello World

', - ], - 'AMP Story Animations' => [ - '', - '', - ], - 'Page Attachment' => [ - '', - '', - ], - 'Images with disable-inline-width' => [ - '', - '', - ], - 'Page Outlink' => [ - 'Read More', - 'Read More', - ], - 'Complex SVG' => [ - $blue_rings_svg, - $blue_rings_svg, - ], - 'Video Captions' => [ - '', - '', - ], - 'Video with Captions ID' => [ - '', - '', - ], - 'Shopping' => [ - '', - '', - ], - 'Shopping with cta-text' => [ - '', - '', - ], - 'AMP Story Audio Sticker' => [ - '', - '', - ], - ]; - } -} diff --git a/tests/phpunit/integration/tests/REST_API/Stories_Autosaves_Controller.php b/tests/phpunit/integration/tests/REST_API/Stories_Autosaves_Controller.php index 90ee67057189..17830b9dc3d8 100644 --- a/tests/phpunit/integration/tests/REST_API/Stories_Autosaves_Controller.php +++ b/tests/phpunit/integration/tests/REST_API/Stories_Autosaves_Controller.php @@ -65,6 +65,7 @@ public function test_create_item_as_author_should_not_strip_markup(): void { $unsanitized_content = (string) file_get_contents( WEB_STORIES_TEST_DATA_DIR . '/story_post_content.html' ); $unsanitized_story_data = json_decode( (string) file_get_contents( WEB_STORIES_TEST_DATA_DIR . '/story_post_content_filtered.json' ), true ); + $sanitized_content = trim( (string) file_get_contents( WEB_STORIES_TEST_DATA_DIR . '/story_post_content_sanitized.html' ) ); $story = self::factory()->post->create( [ @@ -87,7 +88,7 @@ public function test_create_item_as_author_should_not_strip_markup(): void { $this->assertIsArray( $new_data ); $this->assertArrayHasKey( 'content', $new_data ); - $this->assertEquals( $unsanitized_content, $new_data['content']['raw'] ); + $this->assertEquals( $sanitized_content, trim( $new_data['content']['raw'] ) ); $this->assertEquals( $unsanitized_story_data, $new_data['story_data'] ); $this->kses_remove_filters(); diff --git a/tests/phpunit/integration/tests/REST_API/Stories_Controller.php b/tests/phpunit/integration/tests/REST_API/Stories_Controller.php index a6bd01152c59..0d4db072a3f6 100644 --- a/tests/phpunit/integration/tests/REST_API/Stories_Controller.php +++ b/tests/phpunit/integration/tests/REST_API/Stories_Controller.php @@ -947,6 +947,7 @@ public function test_create_item_as_author_should_not_strip_markup(): void { $unsanitized_content = file_get_contents( WEB_STORIES_TEST_DATA_DIR . '/story_post_content.html' ); $unsanitized_story_data = json_decode( (string) file_get_contents( WEB_STORIES_TEST_DATA_DIR . '/story_post_content_filtered.json' ), true ); + $sanitized_content = trim( (string) file_get_contents( WEB_STORIES_TEST_DATA_DIR . '/story_post_content_sanitized.html' ) ); $request = new WP_REST_Request( \WP_REST_Server::CREATABLE, '/web-stories/v1/web-story' ); $request->set_body_params( @@ -958,9 +959,10 @@ public function test_create_item_as_author_should_not_strip_markup(): void { $response = rest_get_server()->dispatch( $request ); $new_data = $response->get_data(); + $this->assertIsArray( $new_data ); $this->assertArrayHasKey( 'content', $new_data ); - $this->assertSame( $unsanitized_content, $new_data['content']['raw'] ); + $this->assertSame( $sanitized_content, trim( $new_data['content']['raw'] ) ); $this->assertSame( $unsanitized_story_data, $new_data['story_data'] ); } @@ -1192,6 +1194,7 @@ public function test_update_item_as_author_should_not_strip_markup(): void { $unsanitized_content = file_get_contents( WEB_STORIES_TEST_DATA_DIR . '/story_post_content.html' ); $unsanitized_story_data = json_decode( (string) file_get_contents( WEB_STORIES_TEST_DATA_DIR . '/story_post_content_filtered.json' ), true ); + $sanitized_content = trim( (string) file_get_contents( WEB_STORIES_TEST_DATA_DIR . '/story_post_content_sanitized.html' ) ); $story = self::factory()->post->create( [ @@ -1214,7 +1217,7 @@ public function test_update_item_as_author_should_not_strip_markup(): void { $this->assertIsArray( $new_data ); $this->assertIsArray( $new_data['content'] ); - $this->assertSame( $unsanitized_content, $new_data['content']['raw'] ); + $this->assertSame( $sanitized_content, trim( $new_data['content']['raw'] ) ); $this->assertSame( $unsanitized_story_data, $new_data['story_data'] ); } }