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">',
+ 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.
*/