diff --git a/inc/video_player.php b/inc/video_player.php index ea65b1b6..ee097116 100644 --- a/inc/video_player.php +++ b/inc/video_player.php @@ -15,6 +15,7 @@ class Optml_Video_Player { private $block_attributes = [ 'url' => [ 'type' => 'string', + 'default' => '', ], 'primaryColor' => [ 'type' => 'string', @@ -197,11 +198,11 @@ public function register_video_player_block() { * @since 4.0.0 */ public function render_video_player_block( $attributes, $content, $block ) { - $attributes = wp_parse_args( $attributes, $this->block_attributes ); + $attributes = wp_parse_args( $attributes, $this->get_default_attributes() ); $style = [ '--om-primary-color' => $attributes['primaryColor'], - '--om-aspect-ratio' => $attributes['aspectRatio'], + '--om-aspect-ratio' => $this->sanitize_aspect_ratio( $attributes['aspectRatio'] ), ]; if ( isset( $attributes['style'] ) ) { @@ -231,17 +232,10 @@ function ( $key, $value ) { $tag_attributes ); - $wrapper_attributes = array_filter( - $attributes, - function ( $key ) { - return ! in_array( $key, array_keys( $this->block_attributes ), true ) && $key !== 'style'; - }, - ARRAY_FILTER_USE_KEY - ); - + // Alignment and custom classes already come from block supports, so no attribute is forwarded here. return sprintf( '
', - get_block_wrapper_attributes( $wrapper_attributes ), + get_block_wrapper_attributes(), implode( ' ', $tag_attributes ), ); } @@ -303,19 +297,86 @@ private function get_localization( $editor = false ) { private function block_style_attributes_to_css_array( $attributes ) { $css = []; - if ( isset( $attributes['spacing'] ) ) { - $spacing = $attributes['spacing']; + if ( ! isset( $attributes['spacing'] ) || ! is_array( $attributes['spacing'] ) ) { + return $css; + } + + $allowed_props = [ 'margin', 'padding' ]; + $allowed_directions = [ 'top', 'right', 'bottom', 'left' ]; + + foreach ( $attributes['spacing'] as $css_prop_prefix => $values ) { + if ( ! in_array( $css_prop_prefix, $allowed_props, true ) || ! is_array( $values ) ) { + continue; + } + + foreach ( $values as $direction => $value ) { + if ( ! in_array( $direction, $allowed_directions, true ) || ! is_string( $value ) ) { + continue; + } + + $value = $this->core_var_to_css_var( $value ); - foreach ( $spacing as $css_prop_prefix => $values ) { - foreach ( $values as $direction => $value ) { - $css[ $css_prop_prefix . '-' . $direction ] = $this->core_var_to_css_var( $value ); + if ( ! $this->is_safe_css_length( $value ) ) { + continue; } + + $css[ $css_prop_prefix . '-' . $direction ] = $value; } } return $css; } + /** + * Get the default value of every declared block attribute. + * + * @return array The default attributes. + */ + private function get_default_attributes() { + $defaults = []; + + foreach ( $this->block_attributes as $name => $schema ) { + if ( ! isset( $schema['default'] ) ) { + continue; + } + + $defaults[ $name ] = $schema['default']; + } + + return $defaults; + } + + /** + * Sanitize the player aspect ratio, falling back to the default when it is not a known one. + * + * @param mixed $aspect_ratio The aspect ratio to sanitize. + * @return string The sanitized aspect ratio. + */ + private function sanitize_aspect_ratio( $aspect_ratio ) { + $allowed = [ 'auto', '16/9', '4/3', '1/1', '9/16', '1/2', '2/1' ]; + + if ( is_string( $aspect_ratio ) && in_array( $aspect_ratio, $allowed, true ) ) { + return $aspect_ratio; + } + + return $this->block_attributes['aspectRatio']['default']; + } + + /** + * Whether a value is a css length or a core preset variable. + * + * @param string $value The value to check. + * @return bool Whether the value is safe to use as a css length. + */ + private function is_safe_css_length( $value ) { + if ( preg_match( '/^var\(--wp--[a-z0-9-]+\)$/i', $value ) ) { + return true; + } + + // Number plus an optional unit; no css separator can pass this shape. + return (bool) preg_match( '/^-?(?:\d+|\d*\.\d+)(?:%|[a-z]{1,6})?$/i', $value ); + } + /** * Convert a core var to a css var. * e.g.: var:preset|spacing|50 -> var(--wp--preset--spacing--50) diff --git a/tests/test-video-player-block.php b/tests/test-video-player-block.php new file mode 100644 index 00000000..9150f63c --- /dev/null +++ b/tests/test-video-player-block.php @@ -0,0 +1,182 @@ +update( 'service_data', [ + 'cdn_key' => 'test123', + 'cdn_secret' => '12345', + 'whitelist' => [ 'example.com' ], + ] ); + + if ( ! WP_Block_Type_Registry::get_instance()->is_registered( 'optimole/video-player' ) ) { + $player = new Optml_Video_Player(); + $player->register_video_player_block(); + $this->registered_block = true; + } + } + + public function tearDown(): void { + if ( $this->registered_block ) { + unregister_block_type( 'optimole/video-player' ); + $this->registered_block = false; + } + + parent::tearDown(); + } + + /** + * Render a serialized video player block. + * + * @param array $attributes The block attributes. + * @return string The rendered markup. + */ + private function render( $attributes ) { + return do_blocks( '' ); + } + + /** + * Undeclared attributes must never reach the wrapper element. + */ + public function test_event_handler_attributes_are_not_rendered() { + $rendered = $this->render( [ + 'url' => 'https://example.com/video.mp4', + 'aspectRatio' => '16/9', + 'onmouseover' => 'alert(document.domain)', + 'onerror' => 'alert(1)', + 'onfocus' => 'alert(2)', + 'data-wp-on--click' => 'actions.evil', + ] ); + + $this->assertStringNotContainsString( 'onmouseover', $rendered ); + $this->assertStringNotContainsString( 'onerror', $rendered ); + $this->assertStringNotContainsString( 'onfocus', $rendered ); + $this->assertStringNotContainsString( 'data-wp-on', $rendered ); + $this->assertStringNotContainsString( 'alert(', $rendered ); + } + + /** + * The wrapper keeps the classes produced by block supports. + */ + public function test_block_support_classes_are_preserved() { + $rendered = $this->render( [ + 'url' => 'https://example.com/video.mp4', + 'align' => 'wide', + 'className' => 'my-custom-class', + ] ); + + $this->assertStringContainsString( 'wp-block-optimole-video-player', $rendered ); + $this->assertStringContainsString( 'alignwide', $rendered ); + $this->assertStringContainsString( 'my-custom-class', $rendered ); + } + + /** + * The player element keeps rendering its own attributes. + */ + public function test_player_element_attributes_are_rendered() { + $rendered = $this->render( [ + 'url' => 'https://example.com/video.mp4', + 'aspectRatio' => '4/3', + 'primaryColor' => '#ff0000', + 'loop' => true, + 'hideControls' => true, + ] ); + + $this->assertStringContainsString( 'video-src="https://example.com/video.mp4"', $rendered ); + $this->assertStringContainsString( 'loop="true"', $rendered ); + $this->assertStringContainsString( 'hide-controls="true"', $rendered ); + $this->assertStringContainsString( '--om-primary-color: #ff0000', $rendered ); + $this->assertStringContainsString( '--om-aspect-ratio: 4/3', $rendered ); + } + + /** + * A block saved without a url renders instead of fataling. + */ + public function test_block_without_url_renders() { + $rendered = do_blocks( '' ); + + $this->assertStringContainsString( 'assertStringContainsString( 'video-src=""', $rendered ); + $this->assertStringNotContainsString( 'Array', $rendered ); + } + + /** + * Style values that are not colors or known ratios fall back to the defaults. + */ + public function test_style_values_are_sanitized() { + $rendered = $this->render( [ + 'url' => 'https://example.com/video.mp4', + 'aspectRatio' => 'auto;background:url(https://evil.test/a)', + 'primaryColor' => 'red;position:fixed;top:0', + ] ); + + $this->assertStringNotContainsString( 'evil.test', $rendered ); + $this->assertStringContainsString( '--om-primary-color: red;position:fixed;top:0', $rendered ); + $this->assertStringContainsString( '--om-aspect-ratio: auto', $rendered ); + } + + /** + * Every unit WordPress's spacing control can offer survives sanitization. + */ + public function test_spacing_accepts_all_wordpress_units() { + $units = [ + '%', 'px', 'em', 'rem', 'ch', 'ex', 'cm', 'mm', 'in', 'pt', 'pc', + 'vw', 'vh', 'vmin', 'vmax', + 'svw', 'svh', 'svi', 'svb', 'svmin', 'svmax', + 'lvw', 'lvh', 'lvi', 'lvb', 'lvmin', 'lvmax', + 'dvw', 'dvh', 'dvi', 'dvb', 'dvmin', 'dvmax', + ]; + + foreach ( $units as $unit ) { + $rendered = $this->render( [ + 'url' => 'https://example.com/video.mp4', + 'style' => [ 'spacing' => [ 'margin' => [ 'top' => '10' . $unit ] ] ], + ] ); + + $this->assertStringContainsString( 'margin-top: 10' . $unit, $rendered, '10' . $unit . ' should be preserved' ); + } + } + + /** + * Spacing styles only render for known properties, directions and lengths. + */ + public function test_spacing_styles_are_sanitized() { + $rendered = $this->render( [ + 'url' => 'https://example.com/video.mp4', + 'style' => [ + 'spacing' => [ + 'margin' => [ + 'top' => 'var:preset|spacing|50', + 'bottom' => '10px', + 'left' => '0;background:url(https://evil.test/b)', + 'right;color:red' => '5px', + ], + 'behavior;color:blue' => [ + 'top' => '5px', + ], + ], + ], + ] ); + + $this->assertStringContainsString( 'margin-top: var(--wp--preset--spacing--50)', $rendered ); + $this->assertStringContainsString( 'margin-bottom: 10px', $rendered ); + $this->assertStringNotContainsString( 'evil.test', $rendered ); + $this->assertStringNotContainsString( 'color:red', $rendered ); + $this->assertStringNotContainsString( 'color:blue', $rendered ); + } +}