Commit add1215f8b for woocommerce
commit add1215f8b45694dc1fe6e851ce8b94bc26ca426
Author: Jan Lysý <lysyjan@users.noreply.github.com>
Date: Fri Jan 16 10:15:37 2026 +0100
Fix personalization tag attributes not being processed in email links (#62822)
* Fix personalization tag attributes in href not being passed to callback
- Handle HTML entity decoding for encoded attributes
- Support unquoted values (when esc_url strips quotes)
- Support values with spaces in unquoted attributes
* Preserve data-link-href attribute in Button block renderer
The data-link-href attribute is used to store personalization tags
for links. This attribute was being stripped when rebuilding the
anchor tag HTML.
* Preserve data-link-href attribute in Image block renderer
The data-link-href attribute is used to store personalization tags
for links. This attribute was being stripped when rebuilding the
anchor tag HTML.
* Add changefile(s) from automation for the following project(s): packages/php/email-editor
* Fix attribute regex to handle embedded quotes correctly
Use separate matchers for double and single quotes to prevent
`title="What's up"` from incorrectly matching as `title="What'`.
* Add ReDoS prevention documentation for attribute parsing regex
---------
Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
diff --git a/packages/php/email-editor/changelog/62822-wooplug-6143-inconsistent-handling-of-personalizer-tag-attributes-in b/packages/php/email-editor/changelog/62822-wooplug-6143-inconsistent-handling-of-personalizer-tag-attributes-in
new file mode 100644
index 0000000000..398e33ec69
--- /dev/null
+++ b/packages/php/email-editor/changelog/62822-wooplug-6143-inconsistent-handling-of-personalizer-tag-attributes-in
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix personalization tag attributes not being processed in email links
\ No newline at end of file
diff --git a/packages/php/email-editor/src/Engine/class-personalizer.php b/packages/php/email-editor/src/Engine/class-personalizer.php
index 3fabb21fc4..b7e9ef1e7b 100644
--- a/packages/php/email-editor/src/Engine/class-personalizer.php
+++ b/packages/php/email-editor/src/Engine/class-personalizer.php
@@ -17,6 +17,12 @@ use Automattic\WooCommerce\EmailEditor\Engine\PersonalizationTags\Personalizatio
*/
class Personalizer {
+ /**
+ * Regex pattern for matching personalization tag names (e.g., "woocommerce/store-url", "user-firstname").
+ * Used in both tag detection and parsing.
+ */
+ private const TAG_NAME_PATTERN = '[a-zA-Z0-9\-\/]+';
+
/**
* Personalization tags registry.
*
@@ -130,7 +136,9 @@ class Personalizer {
continue;
}
- if ( ! $href || ! preg_match( '/\[[a-z-\/]+\]/', urldecode( $href ), $matches ) ) {
+ // Decode both URL encoding (%XX) and HTML entities (') to handle various encoding scenarios.
+ $decoded_href = html_entity_decode( urldecode( $href ), ENT_QUOTES, 'UTF-8' );
+ if ( ! preg_match( '/\[' . self::TAG_NAME_PATTERN . '(?:\s+[^\]]+)?\]/', $decoded_href, $matches ) ) {
continue;
}
@@ -166,14 +174,33 @@ class Personalizer {
);
// Step 1: Separate the tag and attributes.
- if ( preg_match( '/^\[([a-zA-Z0-9\-\/]+)\s*(.*?)\]$/', trim( $token ), $matches ) ) {
+ if ( preg_match( '/^\[(' . self::TAG_NAME_PATTERN . ')\s*(.*?)\]$/', trim( $token ), $matches ) ) {
$result['token'] = "[{$matches[1]}]"; // The tag part (e.g., "[mailpoet/subscriber-firstname]").
$attributes_string = $matches[2]; // The attributes part (e.g., 'default="subscriber"').
// Step 2: Extract attributes from the attribute string.
- if ( preg_match_all( '/(\w+)=["\']([^"\']*)["\']/', $attributes_string, $attribute_matches, PREG_SET_ORDER ) ) {
+ // Match quoted values (double or single quotes separately to avoid mixing) and unquoted values.
+ // Unquoted values can occur when esc_url() strips quotes from personalization tags.
+ // For unquoted values with spaces, capture until the next key= pattern or closing bracket.
+ // The negative lookahead (?!\w+=) is critical for preventing ReDoS:
+ // it ensures the inner loop terminates as soon as the next key= pattern appears,
+ // preventing excessive backtracking despite the nested quantifiers.
+ if ( preg_match_all( '/(\w+)=(?:"([^"]*)"|\'([^\']*)\'|([^\s\]]+(?:\s+(?!\w+=)[^\s\]]+)*))/', $attributes_string, $attribute_matches, PREG_SET_ORDER ) ) {
foreach ( $attribute_matches as $attribute ) {
- $result['arguments'][ $attribute[1] ] = $attribute[2];
+ // $attribute[2] is double-quoted value, $attribute[3] is single-quoted value,
+ // $attribute[4] is unquoted value (may contain spaces).
+ // Use null coalescing as only one of these will be populated depending on which pattern matched.
+ $double_quoted_value = $attribute[2] ?? '';
+ $single_quoted_value = $attribute[3] ?? '';
+ $unquoted_value = $attribute[4] ?? '';
+
+ if ( '' !== $double_quoted_value ) {
+ $result['arguments'][ $attribute[1] ] = $double_quoted_value;
+ } elseif ( '' !== $single_quoted_value ) {
+ $result['arguments'][ $attribute[1] ] = $single_quoted_value;
+ } else {
+ $result['arguments'][ $attribute[1] ] = $unquoted_value;
+ }
}
}
}
diff --git a/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-button.php b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-button.php
index f73e0e67bc..fa30c4845d 100644
--- a/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-button.php
+++ b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-button.php
@@ -88,8 +88,9 @@ class Button extends Abstract_Block_Renderer {
return '';
}
- $button_text = $dom_helper->get_element_inner_html( $button_link ) ? $dom_helper->get_element_inner_html( $button_link ) : '';
- $button_url = $button_link->getAttribute( 'href' ) ? $button_link->getAttribute( 'href' ) : '#';
+ $button_text = $dom_helper->get_element_inner_html( $button_link ) ? $dom_helper->get_element_inner_html( $button_link ) : '';
+ $button_url = $button_link->getAttribute( 'href' ) ? $button_link->getAttribute( 'href' ) : '#';
+ $data_link_href = $button_link->getAttribute( 'data-link-href' );
$block_attributes = wp_parse_args(
$parsed_block['attrs'] ?? array(),
@@ -117,11 +118,16 @@ class Button extends Abstract_Block_Renderer {
'role' => 'presentation',
);
+ $data_link_attr = $data_link_href
+ ? sprintf( ' data-link-href="%s"', esc_attr( $data_link_href ) )
+ : '';
+
$button_content = sprintf(
- '<a class="button-link %1$s" style="%2$s" href="%3$s" target="_blank">%4$s</a>',
+ '<a class="button-link %1$s" style="%2$s" href="%3$s"%4$s target="_blank">%5$s</a>',
esc_attr( $link_styles['classnames'] ),
esc_attr( $link_styles['css'] ),
esc_url( $button_url ),
+ $data_link_attr,
$button_text
);
diff --git a/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-image.php b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-image.php
index af09f8077b..2072f57292 100644
--- a/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-image.php
+++ b/packages/php/email-editor/src/Integrations/Core/Renderer/Blocks/class-image.php
@@ -32,11 +32,12 @@ class Image extends Abstract_Block_Renderer {
return '';
}
- $image_url = $parsed_html['imageUrl'];
- $image = $parsed_html['image'];
- $caption = $parsed_html['caption'];
- $class = $parsed_html['class'];
- $anchor_tag_href = $parsed_html['anchor_tag_href'];
+ $image_url = $parsed_html['imageUrl'];
+ $image = $parsed_html['image'];
+ $caption = $parsed_html['caption'];
+ $class = $parsed_html['class'];
+ $anchor_tag_href = $parsed_html['anchor_tag_href'];
+ $anchor_data_link_href = $parsed_html['anchor_data_link_href'];
$parsed_block = $this->add_image_size_when_missing( $parsed_block, $image_url );
$image = $this->add_image_dimensions( $image, $parsed_block );
@@ -44,7 +45,7 @@ class Image extends Abstract_Block_Renderer {
$image_with_wrapper = str_replace(
array( '{image_content}', '{caption_content}' ),
array( $image, $caption ),
- $this->get_block_wrapper( $parsed_block, $rendering_context, $caption, $anchor_tag_href )
+ $this->get_block_wrapper( $parsed_block, $rendering_context, $caption, $anchor_tag_href, $anchor_data_link_href )
);
$image_with_wrapper = $this->apply_rounded_style( $image_with_wrapper, $parsed_block );
@@ -255,8 +256,9 @@ class Image extends Abstract_Block_Renderer {
* @param Rendering_Context $rendering_context Rendering context.
* @param string|null $caption Caption.
* @param string|null $anchor_tag_href Anchor tag href.
+ * @param string|null $anchor_data_link_href Anchor data-link-href attribute for personalization tags.
*/
- private function get_block_wrapper( array $parsed_block, Rendering_Context $rendering_context, ?string $caption, ?string $anchor_tag_href ): string {
+ private function get_block_wrapper( array $parsed_block, Rendering_Context $rendering_context, ?string $caption, ?string $anchor_tag_href, ?string $anchor_data_link_href = null ): string {
$styles = array(
'border-collapse' => 'collapse',
'border-spacing' => '0px',
@@ -317,9 +319,13 @@ class Image extends Abstract_Block_Renderer {
$image_content = '{image_content}';
if ( $anchor_tag_href ) {
- $image_content = sprintf(
- '<a href="%s" rel="noopener nofollow" target="_blank">%s</a>',
+ $data_link_attr = $anchor_data_link_href
+ ? sprintf( ' data-link-href="%s"', esc_attr( $anchor_data_link_href ) )
+ : '';
+ $image_content = sprintf(
+ '<a href="%s"%s rel="noopener nofollow" target="_blank">%s</a>',
esc_url( $anchor_tag_href ),
+ $data_link_attr,
'{image_content}'
);
}
@@ -374,7 +380,7 @@ class Image extends Abstract_Block_Renderer {
* Parse block content to get image URL, image HTML and caption HTML.
*
* @param string $block_content Block content.
- * @return array{imageUrl: string, image: string, caption: string, class: string, anchor_tag_href: string}|null
+ * @return array{imageUrl: string, image: string, caption: string, class: string, anchor_tag_href: string, anchor_data_link_href: string}|null
*/
private function parse_block_content( string $block_content ): ?array {
// If block's image is not set, we don't need to parse the content.
@@ -401,15 +407,17 @@ class Image extends Abstract_Block_Renderer {
$figcaption_html = $figcaption ? $dom_helper->get_outer_html( $figcaption ) : '';
$figcaption_html = str_replace( array( '<figcaption', '</figcaption>' ), array( '<span', '</span>' ), $figcaption_html );
- $anchor_tag = $dom_helper->find_element( 'a' );
- $anchor_tag_href = $anchor_tag ? $dom_helper->get_attribute_value( $anchor_tag, 'href' ) : '';
+ $anchor_tag = $dom_helper->find_element( 'a' );
+ $anchor_tag_href = $anchor_tag ? $dom_helper->get_attribute_value( $anchor_tag, 'href' ) : '';
+ $anchor_data_link_href = $anchor_tag ? $dom_helper->get_attribute_value( $anchor_tag, 'data-link-href' ) : '';
return array(
- 'imageUrl' => $image_src ? $image_src : '',
- 'image' => $this->cleanup_image_html( $image_html ),
- 'caption' => $figcaption_html ? $figcaption_html : '',
- 'class' => $image_class ? $image_class : '',
- 'anchor_tag_href' => $anchor_tag_href ? $anchor_tag_href : '',
+ 'imageUrl' => $image_src ? $image_src : '',
+ 'image' => $this->cleanup_image_html( $image_html ),
+ 'caption' => $figcaption_html ? $figcaption_html : '',
+ 'class' => $image_class ? $image_class : '',
+ 'anchor_tag_href' => $anchor_tag_href ? $anchor_tag_href : '',
+ 'anchor_data_link_href' => $anchor_data_link_href ? $anchor_data_link_href : '',
);
}
diff --git a/packages/php/email-editor/tests/integration/Engine/Personalizer_Test.php b/packages/php/email-editor/tests/integration/Engine/Personalizer_Test.php
index 655866ab0d..15f638cbba 100644
--- a/packages/php/email-editor/tests/integration/Engine/Personalizer_Test.php
+++ b/packages/php/email-editor/tests/integration/Engine/Personalizer_Test.php
@@ -229,6 +229,28 @@ class Personalizer_Test extends \Email_Editor_Integration_Test_Case {
$this->assertSame( '<a href="http://[woocommerce/non-existent-tag]">Click here</a>', $this->personalizer->personalize_content( $html_content ) );
}
+ /**
+ * Test personalizing content with a tag in href attribute that includes attributes.
+ */
+ public function testPersonalizeContentWithHrefTagWithAttributes(): void {
+ // Register a tag in the registry.
+ $this->tags_registry->register(
+ new Personalization_Tag(
+ 'Trackable Link',
+ 'trackable-link',
+ 'Links',
+ function ( $context, $args ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundBeforeLastUsed -- The $context parameter is not used in this test.
+ return 'https://example.com?url=' . ( $args['url'] ?? '' ) . '&desc=' . ( $args['desc'] ?? '' );
+ }
+ )
+ );
+
+ $html_content = '<a href="[trackable-link url=\'wordpress.com\' desc=\'home-page\']">Click here</a>';
+ // Note: WordPress encodes & as & in URLs.
+ $expected = '<a href="https://example.com?url=wordpress.com&desc=home-page">Click here</a>';
+ $this->assertSame( $expected, $this->personalizer->personalize_content( $html_content ) );
+ }
+
/**
* Test parsing tokens with various formats.
*/
@@ -306,7 +328,102 @@ class Personalizer_Test extends \Email_Editor_Integration_Test_Case {
$result['arguments']
);
- // Test case 6 Invalid token format.
+ // Test case 6: Token with unquoted values (as produced by esc_url stripping quotes).
+ $result = $method->invoke( $this->personalizer, '[trackable-link url=wordpress.com desc=home-page]' );
+ /**
+ * Typehint needed by PHPStan.
+ *
+ * @var array{token: string, arguments: array<string, string>} $result
+ */
+ $this->assertSame( '[trackable-link]', $result['token'] );
+ $this->assertSame(
+ array(
+ 'url' => 'wordpress.com',
+ 'desc' => 'home-page',
+ ),
+ $result['arguments']
+ );
+
+ // Test case 7: Token with unquoted values containing spaces (last argument).
+ $result = $method->invoke( $this->personalizer, '[trackable-link url=uf desc=desc 123 asd]' );
+ /**
+ * Typehint needed by PHPStan.
+ *
+ * @var array{token: string, arguments: array<string, string>} $result
+ */
+ $this->assertSame( '[trackable-link]', $result['token'] );
+ $this->assertSame(
+ array(
+ 'url' => 'uf',
+ 'desc' => 'desc 123 asd',
+ ),
+ $result['arguments']
+ );
+
+ // Test case 8: Token with unquoted values containing spaces (first argument, followed by another).
+ $result = $method->invoke( $this->personalizer, '[trackable-link desc=desc 123 asd url=example.com]' );
+ /**
+ * Typehint needed by PHPStan.
+ *
+ * @var array{token: string, arguments: array<string, string>} $result
+ */
+ $this->assertSame( '[trackable-link]', $result['token'] );
+ $this->assertSame(
+ array(
+ 'desc' => 'desc 123 asd',
+ 'url' => 'example.com',
+ ),
+ $result['arguments']
+ );
+
+ // Test case 9: Token with three unquoted arguments, middle one with spaces.
+ $result = $method->invoke( $this->personalizer, '[trackable-link first=one middle=has some spaces last=three]' );
+ /**
+ * Typehint needed by PHPStan.
+ *
+ * @var array{token: string, arguments: array<string, string>} $result
+ */
+ $this->assertSame( '[trackable-link]', $result['token'] );
+ $this->assertSame(
+ array(
+ 'first' => 'one',
+ 'middle' => 'has some spaces',
+ 'last' => 'three',
+ ),
+ $result['arguments']
+ );
+
+ // Test case 10: Token with embedded single quote in double-quoted value.
+ $result = $method->invoke( $this->personalizer, '[user/greeting title="What\'s up"]' );
+ /**
+ * Typehint needed by PHPStan.
+ *
+ * @var array{token: string, arguments: array<string, string>} $result
+ */
+ $this->assertSame( '[user/greeting]', $result['token'] );
+ $this->assertSame(
+ array(
+ 'title' => "What's up",
+ ),
+ $result['arguments']
+ );
+
+ // Test case 11: Token with embedded double quote in single-quoted value.
+ $result = $method->invoke( $this->personalizer, "[user/greeting title='Say \"hello\"']" );
+ /**
+ * Typehint needed by PHPStan.
+ *
+ * @var array{token: string, arguments: array<string, string>} $result
+ */
+ $this->assertSame( '[user/greeting]', $result['token'] );
+ $this->assertSame(
+ array(
+ 'title' => 'Say "hello"',
+ ),
+ $result['arguments']
+ );
+
+ // Test case 12: Invalid token format.
$result = $method->invoke( $this->personalizer, 'invalid-token' );
/**
* Typehint needed by PHPStan.
diff --git a/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Button_Test.php b/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Button_Test.php
index 7a3607b0c3..d8ad614ca3 100644
--- a/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Button_Test.php
+++ b/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Button_Test.php
@@ -234,4 +234,21 @@ class Button_Test extends \Email_Editor_Integration_Test_Case {
// because of special email HTML markup.
$this->assertStringContainsString( 'color:#fff', $output );
}
+
+ /**
+ * Test it preserves data-link-href attribute for personalization tags
+ */
+ public function testItPreservesDataLinkHref(): void {
+ $parsed_button = $this->parsed_button;
+ $parsed_button['innerHTML'] = '<div class="wp-block-button"><a href="#" data-link-href="[trackable-link url=\'test\']" class="wp-block-button__link">Button Text</a></div>';
+
+ $output = $this->button_renderer->render(
+ $parsed_button['innerHTML'],
+ $parsed_button,
+ $this->rendering_context
+ );
+
+ // Note: esc_attr() encodes single quotes as '.
+ $this->assertStringContainsString( 'data-link-href="[trackable-link url='test']"', $output );
+ }
}
diff --git a/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Image_Test.php b/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Image_Test.php
index 9682a3e9bb..8e13f82303 100644
--- a/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Image_Test.php
+++ b/packages/php/email-editor/tests/integration/Integrations/Core/Renderer/Blocks/Image_Test.php
@@ -372,4 +372,24 @@ class Image_Test extends \Email_Editor_Integration_Test_Case {
$this->assertStringContainsString( 'width="600"', $rendered );
$this->assertStringContainsString( 'width:600px;', $rendered );
}
+
+ /**
+ * Test it preserves data-link-href attribute for personalization tags
+ */
+ public function testItPreservesDataLinkHref(): void {
+ $image_content_with_data_link = '
+ <figure class="wp-block-image alignleft size-full is-style-default">
+ <a href="#" data-link-href="[trackable-link url=\'test\']">
+ <img src="https://test.com/wp-content/uploads/2023/05/image.jpg" alt="" style=""/>
+ </a>
+ </figure>
+ ';
+ $parsed_image = $this->parsed_image;
+ $parsed_image['innerHTML'] = $image_content_with_data_link;
+
+ $rendered = $this->image_renderer->render( $image_content_with_data_link, $parsed_image, $this->rendering_context );
+
+ // Note: esc_attr() encodes single quotes as '.
+ $this->assertStringContainsString( 'data-link-href="[trackable-link url='test']"', $rendered );
+ }
}