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 (&#039;) 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 &#038; in URLs.
+		$expected = '<a href="https://example.com?url=wordpress.com&#038;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 &#039;.
+		$this->assertStringContainsString( 'data-link-href="[trackable-link url=&#039;test&#039;]"', $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 &#039;.
+		$this->assertStringContainsString( 'data-link-href="[trackable-link url=&#039;test&#039;]"', $rendered );
+	}
 }