diff --git a/projects/packages/comments/changelog/fix-comments-youtube-embed-shortcode b/projects/packages/comments/changelog/fix-comments-youtube-embed-shortcode
new file mode 100644
index 000000000000..f002e4fbe899
--- /dev/null
+++ b/projects/packages/comments/changelog/fix-comments-youtube-embed-shortcode
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+Show YouTube and other video players in comments on WordPress.com, and show embeds that need a script, such as TikTok, as links.
diff --git a/projects/packages/comments/src/editor/class-embeds.php b/projects/packages/comments/src/editor/class-embeds.php
index 95359e582620..77c8c9b71751 100644
--- a/projects/packages/comments/src/editor/class-embeds.php
+++ b/projects/packages/comments/src/editor/class-embeds.php
@@ -184,7 +184,7 @@ private static function respond( $data ) {
unset( $data->scripts );
$data->html = self::sanitize_html( $data->html ?? '' );
- // Nothing to show without the script, so the comment will hold the link. Say so now.
+ // No player or photo to show, so the comment will hold the link. Say so now.
if ( '' === $data->html ) {
return new WP_Error( 'oembed_invalid_url', get_status_header_desc( 404 ), array( 'status' => 404 ) );
}
@@ -196,9 +196,8 @@ private static function respond( $data ) {
}
/**
- * A provider's HTML as markup alone, or '' when nothing visible is left. Its script would run
- * first-party for every reader on a commenter's say-so, and so would an inline handler, so the
- * HTML goes through kses with core's post rules plus the iframe the players need.
+ * A provider's player or photo, rebuilt as a bare iframe or img, or '' when it sent neither. Nothing else of
+ * its HTML is kept, so no script or inline handler runs first-party for every reader on a commenter's say-so.
*
* @param mixed $html What the provider sent.
* @return string
@@ -208,35 +207,36 @@ public static function sanitize_html( $html ) {
return '';
}
- // Whole, so a script's body does not survive as text once kses drops its tags.
- $html = (string) preg_replace( '#|$)#is', '', $html );
-
- $allowed = wp_kses_allowed_html( 'post' );
- unset( $allowed['object'] );
- $allowed['iframe'] = array(
- 'src' => true,
- 'width' => true,
- 'height' => true,
- 'title' => true,
- 'allow' => true,
- 'allowfullscreen' => true,
- 'frameborder' => true,
- 'scrolling' => true,
- 'loading' => true,
- 'referrerpolicy' => true,
- 'sandbox' => true,
- 'style' => true,
- 'class' => true,
- 'name' => true,
+ $kept = array(
+ 'IFRAME' => array( 'width', 'height', 'title', 'allow', 'allowfullscreen', 'frameborder', 'loading', 'referrerpolicy', 'sandbox' ),
+ 'IMG' => array( 'width', 'height', 'alt' ),
);
- $html = wp_kses( $html, $allowed );
+ $sent = new \WP_HTML_Tag_Processor( $html );
+ while ( $sent->next_tag() ) {
+ $name = (string) $sent->get_tag();
+ $src = $sent->get_attribute( 'src' );
+ $src = is_string( $src ) ? esc_url_raw( str_starts_with( $src, '//' ) ? 'https:' . $src : $src ) : '';
- if ( ! preg_match( '#<(iframe|img|video|audio)\b#i', $html ) && '' === trim( wp_strip_all_tags( $html ) ) ) {
- return '';
+ if ( ! isset( $kept[ $name ] ) || 'https' !== wp_parse_url( $src, PHP_URL_SCHEME ) ) {
+ continue;
+ }
+
+ // A tag of our own, so only the attributes listed above come along.
+ $tag = new \WP_HTML_Tag_Processor( 'IFRAME' === $name ? '' : '
' );
+ $tag->next_tag();
+ $tag->set_attribute( 'src', $src );
+ foreach ( $kept[ $name ] as $attribute ) {
+ $value = $sent->get_attribute( $attribute );
+ if ( null !== $value ) {
+ $tag->set_attribute( $attribute, $value );
+ }
+ }
+
+ return $tag->get_updated_html();
}
- return $html;
+ return '';
}
/**