Commit 6513682b7be for woocommerce

commit 6513682b7be44b02d60f4cc18723f8aef775a33f
Author: Rostislav Wolný <1082140+costasovo@users.noreply.github.com>
Date:   Thu Jul 30 07:38:27 2026 +0200

    [Email Editor] Harden caption sanitization for table, embed and gallery (#67079)

    * Fix caption sanitizer leaving disallowed markup on malformed input

diff --git a/packages/php/email-editor/changelog/woo6-79-caption-sanitizer-tag-smuggling b/packages/php/email-editor/changelog/woo6-79-caption-sanitizer-tag-smuggling
new file mode 100644
index 00000000000..685ed88d5a9
--- /dev/null
+++ b/packages/php/email-editor/changelog/woo6-79-caption-sanitizer-tag-smuggling
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Ensure block captions keep only the allowed formatting tags and attributes, including when the authored caption markup is malformed.
diff --git a/packages/php/email-editor/src/Integrations/Utils/class-html-processing-helper.php b/packages/php/email-editor/src/Integrations/Utils/class-html-processing-helper.php
index a1c83311f7c..ddc24c04c47 100644
--- a/packages/php/email-editor/src/Integrations/Utils/class-html-processing-helper.php
+++ b/packages/php/email-editor/src/Integrations/Utils/class-html-processing-helper.php
@@ -437,58 +437,91 @@ class Html_Processing_Helper {
 			return $caption_html;
 		}

-		// Remove dangerous content: script, style, and other executable elements.
-		$result = preg_replace( '/<(script|style|iframe|object|embed|form|input|button)\b[^>]*>.*?<\/\1>/is', '', $caption_html );
-		if ( null === $result ) {
-			$caption_html = '';
-		} else {
-			$caption_html = $result;
+		/*
+		 * Remove executable elements together with their content. The allow-list
+		 * pass below keeps the text of the tags it strips, which would turn a
+		 * script body into visible caption text.
+		 *
+		 * One pattern per element rather than a single alternation that backreferences
+		 * the opening name: with the closing tag spelled out as a literal, PCRE can
+		 * reject a caption that never closes the element outright, instead of scanning
+		 * to the end of the caption once for every opening tag it finds.
+		 */
+		foreach ( array( 'script', 'style', 'iframe', 'object', 'embed', 'form', 'input', 'button' ) as $tag ) {
+			$result = preg_replace( '/<' . $tag . '\b[^>]*>.*?<\/' . $tag . '>/is', '', $caption_html );
+			// A caption long enough to exhaust PCRE's limits keeps whatever the pass
+			// managed to remove. wp_kses() below still drops the element itself, so
+			// only the text that was inside it is left behind.
+			if ( null !== $result ) {
+				$caption_html = $result;
+			}
 		}

-		// Use a more conservative approach - only validate attributes, don't modify tags.
-		$allowed_tags = array( 'strong', 'em', 'a', 'mark', 'kbd', 's', 'sub', 'sup', 'span', 'br' );
+		/*
+		 * Reduce the markup to the allowed elements and attributes.
+		 *
+		 * wp_kses() rebuilds each tag it keeps from the allow-list rather than
+		 * passing the authored text through, which is what makes malformed markup
+		 * safe here. A ">" inside an attribute value still ends the tag span early,
+		 * but the allowed tag that falls out of that split is re-emitted with only
+		 * its allowed attributes. A tag left without a closing ">" cannot survive as
+		 * markup either, though which way it goes depends on what is attached to the
+		 * pre_kses hook: core escapes it to text there, and without that callback
+		 * kses drops the tag or supplies the missing ">" itself.
+		 */
+		$caption_html = wp_kses( $caption_html, self::get_allowed_caption_html(), array( 'http', 'https', 'mailto', 'tel' ) );

+		/*
+		 * Narrow the attribute values that survived: protocol allow-list on href,
+		 * safe CSS properties on style, rel and target normalization. Every
+		 * remaining tag is allowed, so every attribute on it is validated.
+		 */
 		$html = new \WP_HTML_Tag_Processor( $caption_html );
-
-		// First pass: Process attributes for allowed tags only.
 		while ( $html->next_tag() ) {
-			// get_tag() returns the tag name uppercased, so normalize before comparing.
-			$tag_name = strtolower( (string) $html->get_tag() );
-
-			// Skip processing for disallowed tags.
-			if ( ! in_array( $tag_name, $allowed_tags, true ) ) {
-				continue;
-			}
-
-			// Only process attributes for allowed tags.
 			$attributes = $html->get_attribute_names_with_prefix( '' );
 			if ( is_array( $attributes ) ) {
 				foreach ( $attributes as $attr_name ) {
-					// Validate and sanitize each attribute individually.
 					self::validate_caption_attribute( $html, $attr_name );
 				}
 			}
 		}

-		// Second pass: strip every tag that is not in the allow-list, keeping its
-		// inner text. This covers opening, closing, self-closing and void tags
-		// (e.g. <img>, <svg>) in one pass.
-		$final_html = $html->get_updated_html();
-
-		$allowed_tags_pattern = implode( '|', array_map( static fn( $tag ) => preg_quote( $tag, '/' ), $allowed_tags ) );
+		return $html->get_updated_html();
+	}

-		/*
-		 * Match a tag whose name is not allowed and remove it:
-		 *   <\/?                       an opening or closing tag.
-		 *   (?! allowed (?=[\s/>]|$) ) skip allowed names, but only when the name
-		 *                              ends at a real tag-name terminator, so a
-		 *                              custom element like "a-b" is not treated
-		 *                              as the allowed "a".
-		 *   [a-z][^>]*>                the tag name and the rest of the tag.
-		 */
-		$result = preg_replace( '/<\/?(?!(?:' . $allowed_tags_pattern . ')(?=[\s\/>]|$))[a-z][^>]*>/is', '', $final_html );
+	/**
+	 * Elements and attributes allowed in captions, in wp_kses() format.
+	 *
+	 * Attribute values are narrowed further by validate_caption_attribute().
+	 *
+	 * @return array<string, array<string, bool>> Allowed HTML for wp_kses().
+	 */
+	private static function get_allowed_caption_html(): array {
+		$common_attributes = array(
+			'class'  => true,
+			'style'  => true,
+			'data-*' => true,
+		);

-		return null === $result ? '' : $result;
+		return array(
+			'a'      => array_merge(
+				$common_attributes,
+				array(
+					'href'   => true,
+					'target' => true,
+					'rel'    => true,
+				)
+			),
+			'br'     => $common_attributes,
+			'em'     => $common_attributes,
+			'kbd'    => $common_attributes,
+			'mark'   => $common_attributes,
+			's'      => $common_attributes,
+			'span'   => $common_attributes,
+			'strong' => $common_attributes,
+			'sub'    => $common_attributes,
+			'sup'    => $common_attributes,
+		);
 	}

 	/**
diff --git a/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Embed_Test.php b/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Embed_Test.php
index 58d852b4759..0bf0f8aad3d 100644
--- a/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Embed_Test.php
+++ b/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Embed_Test.php
@@ -700,6 +700,32 @@ class Embed_Test extends \Email_Editor_Integration_Test_Case {
 		$this->assertStringNotContainsString( '<script>', $rendered );
 	}

+	/**
+	 * Test that a malformed caption cannot be completed by the wrapper markup.
+	 *
+	 * The renderer appends "</div>" after the sanitized caption, which a browser
+	 * consumes as part of any tag the caption leaves open. The sanitized caption
+	 * therefore has to be complete markup on its own, so that the wrapper cannot
+	 * turn a fragment into a live element in the browser-rendered editor preview.
+	 */
+	public function test_malformed_caption_does_not_survive_into_output(): void {
+		$parsed_embed = array(
+			'blockName' => 'core/embed',
+			'attrs'     => array(
+				'url'              => 'https://example.com/some-content',
+				'providerNameSlug' => 'unsupported',
+			),
+			'innerHTML' => '<figure class="wp-block-embed"><div class="wp-block-embed__wrapper">https://example.com/some-content</div><figcaption class="wp-element-caption">caption<img src=x onerror="alert(1)"</figcaption></figure>',
+		);
+
+		$rendered = $this->embed_renderer->render( $parsed_embed['innerHTML'], $parsed_embed, $this->rendering_context );
+
+		// The caption text is wrapped and appended, and the <img> is gone however
+		// the sanitizer disposed of it, so the trailing "</div>" completes nothing.
+		$this->assertStringContainsString( '<div style="text-align: center; margin-top: 8px;">caption', $rendered );
+		$this->assertStringNotContainsString( '<img', $rendered );
+	}
+
 	/**
 	 * Test that VideoPress embed is detected and renders as video player, including handling URLs with query parameters.
 	 */
diff --git a/packages/php/email-editor/tests/integration/Integrations/Utils/Html_Processing_Helper_Test.php b/packages/php/email-editor/tests/integration/Integrations/Utils/Html_Processing_Helper_Test.php
index 5bd1cdcbeb6..8facb2b27eb 100644
--- a/packages/php/email-editor/tests/integration/Integrations/Utils/Html_Processing_Helper_Test.php
+++ b/packages/php/email-editor/tests/integration/Integrations/Utils/Html_Processing_Helper_Test.php
@@ -13,8 +13,9 @@ use Automattic\WooCommerce\EmailEditor\Integrations\Utils\Html_Processing_Helper
 /**
  * Integration test for Html_Processing_Helper.
  *
- * These run against the real WordPress WP_HTML_Tag_Processor, which the unit
- * tests only stub, so caption sanitization is exercised end to end.
+ * Caption sanitization is only meaningful against the real WP_HTML_Tag_Processor
+ * and wp_kses(), so it is covered here rather than in the unit suite, which stubs
+ * the HTML API.
  */
 class Html_Processing_Helper_Test extends \Email_Editor_Integration_Test_Case {
 	/**
@@ -50,6 +51,91 @@ class Html_Processing_Helper_Test extends \Email_Editor_Integration_Test_Case {
 		);
 	}

+	/**
+	 * A tag smuggled in through a ">" in an attribute value must not keep its
+	 * attributes.
+	 *
+	 * The ">" in "<img alt="a><em onclick=alert(1)>x">" belongs to the alt value,
+	 * but the tag span is still cut there, so an <em> falls out of the split. What
+	 * has to hold is that it is rebuilt from the allow-list: the onclick is gone,
+	 * the href below is gone, and the leftover text is escaped rather than markup.
+	 */
+	public function test_sanitize_caption_html_neutralizes_tag_smuggling_via_attribute_values(): void {
+		$this->assertSame(
+			'<em>x"&gt;',
+			Html_Processing_Helper::sanitize_caption_html( '<img alt="a><em onclick=alert(1)>x">' )
+		);
+
+		$smuggled_link = Html_Processing_Helper::sanitize_caption_html( '<img alt="a><a href="javascript:alert(1)">click</a>">' );
+		$this->assertStringNotContainsString( 'href', $smuggled_link );
+		$this->assertStringContainsString( 'click', $smuggled_link );
+	}
+
+	/**
+	 * A tag without a closing ">" must not survive as markup.
+	 *
+	 * Callers wrap the sanitized caption in markup (Embed::render_caption appends
+	 * "</div>"), and a browser consumes that wrapper as part of any tag the caption
+	 * leaves open, so the caption has to be complete markup on its own.
+	 *
+	 * Which way an unterminated tag goes depends on what is attached to the
+	 * pre_kses hook — core escapes it to text, and without that callback kses drops
+	 * the tag or supplies the missing ">". The assertions cover what holds either
+	 * way, so they do not turn red on a caller that filters differently.
+	 */
+	public function test_sanitize_caption_html_neutralizes_unterminated_tags(): void {
+		$unterminated_img = Html_Processing_Helper::sanitize_caption_html( 'caption<img src=x onerror="alert(1)"' );
+		$this->assertStringContainsString( 'caption', $unterminated_img );
+		$this->assertStringNotContainsString( '<img', $unterminated_img );
+		$this->assertNoEventHandlers( $unterminated_img );
+
+		// An unterminated tag that is on the list may be rebuilt rather than
+		// escaped, but never keeps an attribute the allow-list rejects.
+		$unterminated_link = Html_Processing_Helper::sanitize_caption_html( 'caption<a href="https://x.test" onmouseover="alert(1)"' );
+		$this->assertStringContainsString( 'caption', $unterminated_link );
+		$this->assertNoEventHandlers( $unterminated_link );
+	}
+
+	/**
+	 * Assert that no tag in the given HTML carries an event-handler attribute.
+	 *
+	 * Checks the parsed attributes rather than the serialized string, so a handler
+	 * that only survives as escaped text does not count as a failure.
+	 *
+	 * @param string $html Sanitized HTML to inspect.
+	 */
+	private function assertNoEventHandlers( string $html ): void {
+		$processor = new \WP_HTML_Tag_Processor( $html );
+		while ( $processor->next_tag() ) {
+			$handlers = $processor->get_attribute_names_with_prefix( 'on' );
+			$this->assertSame(
+				array(),
+				is_array( $handlers ) ? $handlers : array(),
+				sprintf( 'Event handler survived on <%s> in: %s', strtolower( (string) $processor->get_tag() ), $html )
+			);
+		}
+	}
+
+	/**
+	 * A caption of never-closed executable tags must stay cheap to sanitize.
+	 *
+	 * The executable-element pass matches lazily, so with the closing tag left to a
+	 * backreference every unclosed opening tag costs a scan to the end of the
+	 * caption, and a few hundred kilobytes take seconds. This caption is also large
+	 * enough to exhaust PCRE's backtrack limit, which must leave the author's text
+	 * alone rather than discard the caption.
+	 */
+	public function test_sanitize_caption_html_handles_many_unclosed_executable_tags(): void {
+		$caption = str_repeat( '<script>', 131072 ) . 'My <strong>caption</strong> text';
+
+		$started = microtime( true );
+		$result  = Html_Processing_Helper::sanitize_caption_html( $caption );
+		$elapsed = microtime( true ) - $started;
+
+		$this->assertSame( 'My <strong>caption</strong> text', $result );
+		$this->assertLessThan( 5, $elapsed, 'Sanitizing a caption of unclosed tags should not take seconds.' );
+	}
+
 	/**
 	 * Safe caption markup must be preserved.
 	 */
@@ -59,10 +145,54 @@ class Html_Processing_Helper_Test extends \Email_Editor_Integration_Test_Case {
 			Html_Processing_Helper::sanitize_caption_html( 'My <strong>video</strong> caption' )
 		);

+		$this->assertSame(
+			'This is plain text',
+			Html_Processing_Helper::sanitize_caption_html( 'This is plain text' )
+		);
+
+		$this->assertSame( '', Html_Processing_Helper::sanitize_caption_html( '' ) );
+
+		$nested = '<strong><em><a href="https://example.com">Nested <mark>highlighted</mark> link</a></em></strong>';
+		$this->assertSame( $nested, Html_Processing_Helper::sanitize_caption_html( $nested ) );
+
 		// A valid link is preserved; target="_blank" gains rel="noopener noreferrer".
 		$linked = Html_Processing_Helper::sanitize_caption_html( '<a href="https://example.com" target="_blank">link</a>' );
 		$this->assertStringContainsString( 'href="https://example.com"', $linked );
 		$this->assertStringContainsString( 'target="_blank"', $linked );
-		$this->assertStringContainsString( 'noopener', $linked );
+		$this->assertStringContainsString( 'rel="noopener noreferrer"', $linked );
+	}
+
+	/**
+	 * Disallowed elements are removed but their text content is kept, except for
+	 * executable elements, which are dropped together with their content.
+	 */
+	public function test_sanitize_caption_html_unwraps_disallowed_elements(): void {
+		$this->assertSame(
+			'Not allowed<strong>Bold</strong><em>italic</em>',
+			Html_Processing_Helper::sanitize_caption_html( '<div>Not allowed</div><strong>Bold</strong><script>alert("xss")</script><em>italic</em>' )
+		);
+
+		// Void and self-closing elements are covered too; "<br/>" is normalized.
+		$this->assertSame(
+			'<br /><strong>Bold</strong><em>italic</em>',
+			Html_Processing_Helper::sanitize_caption_html( '<br/><strong>Bold</strong><hr/><em>italic</em>' )
+		);
+	}
+
+	/**
+	 * Attribute values on allowed elements are narrowed to the caption rules.
+	 */
+	public function test_sanitize_caption_html_narrows_attribute_values(): void {
+		// Only the safe CSS properties survive.
+		$this->assertSame(
+			'<span style="font-size: 14px">x</span>',
+			Html_Processing_Helper::sanitize_caption_html( '<span style="font-size: 14px; position: absolute">x</span>' )
+		);
+
+		// data-* values must be a single token; "a b" is rejected.
+		$attributed = Html_Processing_Helper::sanitize_caption_html( '<span class="ok" data-id="5" data-bad="a b">x</span>' );
+		$this->assertStringContainsString( 'class="ok"', $attributed );
+		$this->assertStringContainsString( 'data-id="5"', $attributed );
+		$this->assertStringNotContainsString( 'data-bad', $attributed );
 	}
 }
diff --git a/packages/php/email-editor/tests/unit/Integrations/Utils/Html_Processing_Helper_Test.php b/packages/php/email-editor/tests/unit/Integrations/Utils/Html_Processing_Helper_Test.php
index 0e22d2095b4..c6886529786 100644
--- a/packages/php/email-editor/tests/unit/Integrations/Utils/Html_Processing_Helper_Test.php
+++ b/packages/php/email-editor/tests/unit/Integrations/Utils/Html_Processing_Helper_Test.php
@@ -540,89 +540,6 @@ class Html_Processing_Helper_Test extends \Email_Editor_Unit_Test {
 		$this->assertEquals( $expected_properties, $result );
 	}

-	/**
-	 * Test sanitize_caption_html with plain text.
-	 */
-	public function testSanitizeCaptionHtmlWithPlainText(): void {
-		$html   = 'This is plain text';
-		$result = Html_Processing_Helper::sanitize_caption_html( $html );
-		$this->assertEquals( $html, $result );
-	}
-
-	/**
-	 * Test sanitize_caption_html allows safe tags.
-	 */
-	public function testSanitizeCaptionHtmlAllowsSafeTags(): void {
-		$html   = '<strong>Bold</strong> <em>italic</em> <a href="https://example.com">link</a>';
-		$result = Html_Processing_Helper::sanitize_caption_html( $html );
-		$this->assertEquals( $html, $result );
-	}
-
-	/**
-	 * Test sanitize_caption_html removes dangerous tags.
-	 */
-	public function testSanitizeCaptionHtmlRemovesDangerousTags(): void {
-		$html   = '<script>alert("xss")</script><strong>Bold</strong>';
-		$result = Html_Processing_Helper::sanitize_caption_html( $html );
-		$this->assertEquals( '<strong>Bold</strong>', $result );
-	}
-
-	/**
-	 * Test sanitize_caption_html removes dangerous content.
-	 */
-	public function testSanitizeCaptionHtmlRemovesDangerousContent(): void {
-		$html   = '<style>body { background: red; }</style><strong>Bold</strong>';
-		$result = Html_Processing_Helper::sanitize_caption_html( $html );
-		$this->assertEquals( '<strong>Bold</strong>', $result );
-	}
-
-	/**
-	 * Test sanitize_caption_html sanitizes attributes.
-	 */
-	public function testSanitizeCaptionHtmlSanitizesAttributes(): void {
-		$html   = '<a href="javascript:alert(\'xss\')" target="_blank">Link</a>';
-		$result = Html_Processing_Helper::sanitize_caption_html( $html );
-		// Note: Our mock WP_HTML_Tag_Processor doesn't fully implement HTML reconstruction.
-		// This test verifies the method processes the HTML without errors.
-		$this->assertIsString( $result );
-		$this->assertStringContainsString( 'Link', $result );
-	}
-
-	/**
-	 * Test sanitize_caption_html with mixed content.
-	 */
-	public function testSanitizeCaptionHtmlWithMixedContent(): void {
-		$html   = '<div>Not allowed</div><strong>Bold</strong><script>alert("xss")</script><em>italic</em>';
-		$result = Html_Processing_Helper::sanitize_caption_html( $html );
-		$this->assertEquals( 'Not allowed<strong>Bold</strong><em>italic</em>', $result );
-	}
-
-	/**
-	 * Test sanitize_caption_html with self-closing tags.
-	 */
-	public function testSanitizeCaptionHtmlWithSelfClosingTags(): void {
-		$html   = '<br/><strong>Bold</strong><hr/><em>italic</em>';
-		$result = Html_Processing_Helper::sanitize_caption_html( $html );
-		$this->assertEquals( '<br/><strong>Bold</strong><em>italic</em>', $result );
-	}
-
-	/**
-	 * Test sanitize_caption_html with empty string.
-	 */
-	public function testSanitizeCaptionHtmlWithEmptyString(): void {
-		$result = Html_Processing_Helper::sanitize_caption_html( '' );
-		$this->assertEquals( '', $result );
-	}
-
-	/**
-	 * Test sanitize_caption_html with complex nested content.
-	 */
-	public function testSanitizeCaptionHtmlWithComplexNestedContent(): void {
-		$html   = '<strong><em><a href="https://example.com">Nested <mark>highlighted</mark> link</a></em></strong>';
-		$result = Html_Processing_Helper::sanitize_caption_html( $html );
-		$this->assertEquals( $html, $result );
-	}
-
 	/**
 	 * Test sanitize_image_html with basic image.
 	 */