PluginProbe ʕ •ᴥ•ʔ
MailPoet – Newsletters, Email Marketing, and Automation / 5.36.0
MailPoet – Newsletters, Email Marketing, and Automation v5.36.0
5.36.0 5.35.1 5.35.0 5.34.3 5.34.2 5.34.1 5.34.0 5.33.1 5.33.0 5.32.0 5.31.0 5.30.0 5.29.0 5.28.1 5.28.0 5.27.0 5.26.0 5.26.1 5.25.0 5.24.0 4.43.0 4.43.1 4.44.0 4.44.1 4.45.0 4.46.0 4.47.0 4.48.0 4.48.1 4.48.2 4.49.0 4.49.1 4.5.0 4.5.1 4.5.2 4.50.0 4.50.1 4.51.0 4.51.1 4.51.2 4.52.0 4.53.0 4.54.0 4.55.0 4.56.0 4.57.0 4.58.0 4.58.1 4.58.2 4.6.0 4.6.1 4.6.2 4.7.0 4.7.1 4.8.0 4.8.1 4.9.0 5.0.0 5.0.1 5.0.2 5.1.0 5.1.1 5.10.0 5.10.1 5.11.0 5.12.0 5.12.1 5.12.10 5.12.11 5.12.12 5.12.13 5.12.2 5.12.3 5.12.4 5.12.5 5.12.6 5.12.7 5.12.8 5.12.9 5.13.0 5.13.1 5.13.2 5.14.0 5.14.1 5.14.2 5.14.3 5.15.0 5.15.1 5.16.0 5.16.1 5.16.2 5.16.3 5.16.4 5.17.0 5.17.1 5.17.2 5.17.3 5.17.4 5.17.5 5.17.6 5.18.0 5.19.0 5.2.0 5.2.1 5.2.2 5.2.3 5.20.0 5.21.0 5.21.1 5.21.2 5.21.3 5.22.0 5.22.1 5.22.2 5.22.3 5.22.4 5.23.0 5.23.1 5.23.2 5.3.0 5.3.1 5.3.2 5.3.3 5.3.4 5.3.5 5.3.6 5.3.7 5.4.0 5.4.1 5.4.2 5.5.0 5.5.1 5.5.2 5.6.0 5.6.1 5.6.2 5.6.3 5.6.4 5.7.0 5.7.1 5.8.0 5.8.1 5.9.0 3.0.0-beta.15 3.7.1 3.0.0-beta.16 3.7.2 3.0.0-beta.17 3.7.3 3.0.0-beta.18 3.7.4 3.0.0-beta.19 3.7.5 3.0.0-beta.2 3.7.6 3.0.0-beta.20 3.7.8 3.0.0-beta.21 3.70.0 3.0.0-beta.22 3.71.0 3.0.0-beta.23 3.71.1 3.0.0-beta.23.1 3.71.2 3.0.0-beta.23.2 3.71.3 3.0.0-beta.24 3.72.0 3.0.0-beta.25 3.73.0 3.0.0-beta.26 3.73.1 3.0.0-beta.27 3.73.2 3.0.0-beta.28 3.74.0 3.0.0-beta.29 3.74.1 3.0.0-beta.3 3.74.2 3.0.0-beta.30 3.74.3 3.0.0-beta.31 3.75.0 3.0.0-beta.32 3.75.1 3.0.0-beta.33 3.76.0 3.0.0-beta.33.1 3.77.0 3.0.0-beta.34.0.0 3.77.1 3.0.0-beta.36.0.0 3.78.0 3.0.0-beta.36.0.1 3.79.0 3.0.0-beta.36.2.0 3.8 3.0.0-beta.36.3.0 3.8.1 3.0.0-beta.36.3.1 3.8.2 3.0.0-beta.37.0.0 3.8.3 3.0.0-beta.4 3.8.4 3.0.0-beta.5 3.8.5 3.0.0-beta.6 3.8.6 3.0.0-beta.7 3.80.0 3.0.0-beta.7.1 3.81.0 3.0.0-beta.8 3.82.0 3.0.0-beta.9 3.83.0 3.0.0-rc.1.0.0 3.84.0 3.0.0-rc.1.0.1 3.84.1 3.0.0-rc.1.0.2 3.85.0 3.0.0-rc.1.0.3 3.85.1 3.0.0-rc.1.0.4 3.86.0 3.0.0-rc.2.0.0 3.87.0 3.0.0-rc.2.0.1 3.87.1 3.0.0-rc.2.0.2 3.87.2 3.0.0-rc.2.0.3 3.88.0 3.0.1 3.88.1 3.0.2 3.88.2 3.0.3 3.89.0 3.0.4 3.89.1 3.0.5 3.89.2 3.0.6 3.89.3 3.0.7 3.89.4 3.0.8 3.9.0 3.0.9 3.9.1 3.1.0 3.90.0 3.10 3.90.1 3.10.1 3.90.2 3.100.0 3.91.0 3.100.1 3.91.1 3.100.2 3.92.0 3.101.0 3.92.1 3.101.1 3.93.0 3.102.0 3.93.1 3.102.1 3.94.0 3.103.0 3.95.0 3.103.1 3.95.1 3.11.0 3.96.0 3.11.1 3.96.1 3.11.2 3.97.0 3.11.3 3.98.0 3.11.4 3.98.1 3.11.5 3.99.0 3.12.0 3.99.1 3.12.1 4.0.0 3.13.0 4.0.1 3.14.0 4.1.0 3.14.1 4.1.1 3.15.0 4.10.0 3.16.0 4.11.0 3.16.1 4.11.1 3.16.2 4.12.0 3.16.3 4.12.1 3.17.0 4.12.2 3.17.1 4.13.0 3.17.2 4.14.0 3.18.0 4.15.0 3.18.1 4.16.0 3.18.2 4.17.0 3.19.0 4.17.1 3.19.1 4.18.0 3.19.2 4.18.1 3.19.3 4.19.0 3.2.0 4.2.0 3.2.1 4.20.0 3.2.2 4.20.1 3.2.3 4.20.2 3.2.4 4.21.0 3.2.5 4.22.0 3.20.0 4.22.1 3.21.0 4.22.2 3.21.1 4.23.0 3.22.0 4.24.0 3.23.0 4.25.0 3.23.1 4.26.0 3.23.2 4.26.1 3.24.0 4.27.0 3.25.0 4.28.0 3.25.1 4.29.0 3.26.0 4.3.0 3.26.1 4.3.1 3.27.0 4.30.0 3.28.0 4.31.0 3.29.0 4.31.1 3.3.0 4.32.0 3.3.1 4.33.0 3.3.2 4.34.0 3.3.3 4.35.0 3.3.4 4.35.1 3.3.5 4.36.0 3.3.6 4.37.0 3.30.0 4.38.0 3.31.0 4.39.0 3.31.1 4.4.0 3.32.0 4.40.0 3.32.1 4.41.0 3.32.2 4.41.1 3.33.0 4.41.2 3.34.0 4.41.3 3.34.1 4.42.0 3.34.2 4.42.1 3.34.3 3.34.4 3.35.0 3.35.1 3.35.3 3.35.4 3.36.0 3.37.0 3.37.1 3.37.2 3.37.3 3.38.0 3.38.1 3.39.0 3.39.1 3.39.2 3.4.0 3.4.1 3.4.2 3.4.3 3.4.4 3.40.0 3.40.1 3.41.0 3.41.1 3.41.2 3.42.0 3.42.1 3.42.2 3.42.3 3.43.0 3.43.1 3.44.0 3.45.0 3.45.1 3.46.0 3.46.1 3.46.10 3.46.11 3.46.12 3.46.13 3.46.14 3.46.2 3.46.3 3.46.4 3.46.5 3.46.6 3.46.7 3.46.8 3.46.9 3.47.0 3.47.1 3.47.10 3.47.11 3.47.2 3.47.3 3.47.5 3.47.6 3.47.7 3.47.9 3.48.0 3.48.1 3.49.0 3.49.1 3.5.0 3.5.1 3.50.0 3.51.0 3.51.1 3.51.2 3.52.0 3.53.0 3.54.0 3.54.1 3.54.2 3.54.3 3.55.0 3.55.1 3.56.0 3.56.1 3.56.2 3.57.0 3.57.1 3.58.0 3.59.0 3.59.1 3.59.2 3.6.0 3.6.1 3.6.2 3.6.3 3.6.4 3.6.5 3.6.6 3.6.7 3.60.0 3.60.1 3.60.10 3.60.11 3.60.12 3.60.2 3.60.3 3.60.4 3.60.6 3.60.7 3.60.8 3.60.9 3.61.0 3.62.0 3.62.1 3.63.0 3.64.0 3.64.1 3.64.2 3.64.3 3.65.0 trunk 3.65.1 3.0.0 3.66.0 3.0.0-beta.1 3.67.0 3.0.0-beta.10 3.67.1 3.0.0-beta.11 3.68.0 3.0.0-beta.12 3.69.0 3.0.0-beta.13 3.69.1 3.0.0-beta.14 3.7.0
mailpoet / vendor / woocommerce / email-editor / src / Integrations / Utils / class-html-processing-helper.php
mailpoet / vendor / woocommerce / email-editor / src / Integrations / Utils Last commit date
class-dom-document-helper.php 2 weeks ago class-html-processing-helper.php 2 weeks ago class-social-links-helper.php 11 months ago class-styles-helper.php 6 months ago class-table-wrapper-helper.php 11 months ago index.php 11 months ago
class-html-processing-helper.php
457 lines
1 <?php
2 declare( strict_types = 1 );
3 namespace Automattic\WooCommerce\EmailEditor\Integrations\Utils;
4 if (!defined('ABSPATH')) exit;
5 class Html_Processing_Helper {
6 public static function clean_css_classes( string $classes ): string {
7 // Limit input length to prevent DoS attacks.
8 if ( strlen( $classes ) > 1000 ) {
9 $classes = substr( $classes, 0, 1000 );
10 }
11 // Remove generic background classes but keep specific color classes.
12 $result = preg_replace( '/\bhas-background\b/', '', $classes );
13 if ( null === $result ) {
14 $classes = '';
15 } else {
16 $classes = $result;
17 }
18 // Remove border classes.
19 $result = preg_replace( '/\bhas-[a-z-]*border[a-z-]*\b/', '', $classes );
20 if ( null === $result ) {
21 $classes = '';
22 } else {
23 $classes = $result;
24 }
25 $result = preg_replace( '/\b[a-z-]+-border-[a-z-]+\b/', '', $classes );
26 if ( null === $result ) {
27 $classes = '';
28 } else {
29 $classes = $result;
30 }
31 // Clean up multiple spaces.
32 $result = preg_replace( '/\s+/', ' ', $classes );
33 if ( null === $result ) {
34 $classes = '';
35 } else {
36 $classes = $result;
37 }
38 return trim( $classes );
39 }
40 public static function sanitize_css_value( string $value ): string {
41 // Remove dangerous script injection characters (angle brackets) but preserve quotes for CSS strings.
42 $result = preg_replace( '/[<>]/', '', $value );
43 if ( null === $result ) {
44 $value = '';
45 } else {
46 $value = $result;
47 }
48 // Remove dangerous CSS functions and expressions.
49 $dangerous_patterns = array(
50 '/expression\s*\(/i',
51 '/url\s*\(\s*javascript\s*:/i',
52 '/url\s*\(\s*data\s*:/i',
53 '/url\s*\(\s*vbscript\s*:/i',
54 '/import\s*\(/i',
55 '/behavior\s*:/i',
56 '/binding\s*:/i',
57 '/filter\s*:/i',
58 '/progid\s*:/i',
59 );
60 foreach ( $dangerous_patterns as $pattern ) {
61 if ( preg_match( $pattern, $value ) ) {
62 return '';
63 }
64 }
65 return trim( $value );
66 }
67 public static function sanitize_dimension_value( $value ): string {
68 if ( ! is_string( $value ) && ! is_numeric( $value ) ) {
69 return '';
70 }
71 $value = (string) $value;
72 // If it's just a number, assume pixels.
73 if ( is_numeric( $value ) ) {
74 $value = $value . 'px';
75 }
76 // Use existing CSS value sanitization for security.
77 $sanitized_value = self::sanitize_css_value( $value );
78 // Additional validation for dimension-specific units.
79 if ( ! empty( $sanitized_value ) && preg_match( '/^(\d+(?:\.\d+)?)(px|em|rem|%|vh|vw|ex|ch|in|cm|mm|pt|pc)$/', $sanitized_value ) ) {
80 return $sanitized_value;
81 }
82 return '';
83 }
84 public static function sanitize_color( string $color ): string {
85 // Remove any whitespace.
86 $color = trim( $color );
87 // Check if it's a valid hex color (#fff, #ffffff, #ffffffff).
88 if ( preg_match( '/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/', $color ) ) {
89 return strtolower( $color );
90 }
91 // Check for rgb/rgba colors.
92 if ( preg_match( '/^rgba?\(\s*(25[0-5]|2[0-4]\d|1\d{2}|\d{1,2})\s*,\s*(25[0-5]|2[0-4]\d|1\d{2}|\d{1,2})\s*,\s*(25[0-5]|2[0-4]\d|1\d{2}|\d{1,2})\s*(?:,\s*(?:1(?:\.0+)?|0(?:\.\d+)?|\.\d+)\s*)?\)$/', $color ) ) {
93 return $color;
94 }
95 // Check for hsl/hsla colors.
96 if ( preg_match( '/^hsla?\(\s*(360|3[0-5]\d|[12]\d{2}|\d{1,2})\s*,\s*(100|[1-9]?\d)%\s*,\s*(100|[1-9]?\d)%\s*(?:,\s*(?:1(?:\.0+)?|0(?:\.\d+)?|\.\d+)\s*)?\)$/', $color ) ) {
97 return $color;
98 }
99 // Check for named colors and other valid CSS color values.
100 // We use a permissive approach: accept any string that doesn't contain dangerous characters
101 // and let the CSS engine handle the actual validation.
102 if ( preg_match( '/^[a-zA-Z][a-zA-Z0-9-]*$/', $color ) && ! preg_match( '/^(expression|javascript|vbscript|data|import|behavior|binding|filter|progid)/i', $color ) ) {
103 return strtolower( $color );
104 }
105 // Check if it's a CSS variable (var(--variable-name)).
106 if ( preg_match( '/^var\(--[a-zA-Z0-9\-_]+\)$/', $color ) ) {
107 return $color;
108 }
109 // If not a valid color format, return a safe default.
110 return '#000000';
111 }
112 private static function normalize_rel_attribute( ?string $rel_value, bool $require_security_tokens = false ): string {
113 $allowed_tokens = array( 'noopener', 'noreferrer', 'nofollow', 'external' );
114 $required_tokens = $require_security_tokens ? array( 'noopener', 'noreferrer' ) : array();
115 // If no rel value and no required tokens, return empty.
116 if ( null === $rel_value && empty( $required_tokens ) ) {
117 return '';
118 }
119 // Start with required tokens.
120 $tokens = $required_tokens;
121 // If rel value exists, parse and normalize it.
122 if ( null !== $rel_value ) {
123 $existing_tokens = preg_split( '/\s+/', trim( $rel_value ) );
124 if ( false !== $existing_tokens ) {
125 // Normalize existing tokens: lowercase, remove empty, filter allowed.
126 $normalized_existing = array_filter(
127 array_map( 'strtolower', $existing_tokens ),
128 function ( $token ) use ( $allowed_tokens ) {
129 return ! empty( $token ) && in_array( $token, $allowed_tokens, true );
130 }
131 );
132 // Merge with required tokens, removing duplicates.
133 $tokens = array_unique( array_merge( $tokens, $normalized_existing ) );
134 }
135 }
136 // Return normalized rel attribute or empty string if no valid tokens.
137 return empty( $tokens ) ? '' : implode( ' ', $tokens );
138 }
139 public static function validate_caption_attribute( \WP_HTML_Tag_Processor $html, string $attr_name ): void {
140 $attr_value = $html->get_attribute( $attr_name );
141 if ( null === $attr_value ) {
142 return;
143 }
144 // Block all event handler attributes (on*) - Critical security fix.
145 if ( str_starts_with( $attr_name, 'on' ) ) {
146 $html->remove_attribute( $attr_name );
147 return;
148 }
149 switch ( $attr_name ) {
150 case 'href':
151 // Only allow http, https, mailto, and tel protocols.
152 if ( ! preg_match( '/^(https?:\/\/|mailto:|tel:)/i', (string) $attr_value ) ) {
153 $html->remove_attribute( $attr_name );
154 break;
155 }
156 // Sanitize and normalize the URL using WordPress's esc_url_raw.
157 $sanitized_url = esc_url_raw( (string) $attr_value );
158 if ( empty( $sanitized_url ) ) {
159 // If esc_url_raw returns empty, the URL was invalid - remove the attribute.
160 $html->remove_attribute( $attr_name );
161 } else {
162 // Set the attribute to the sanitized/normalized value.
163 $html->set_attribute( $attr_name, $sanitized_url );
164 }
165 break;
166 case 'target':
167 // Allow only common safe targets.
168 $allowed_targets = array( '_blank', '_self' );
169 $target_value = strtolower( (string) $attr_value );
170 if ( ! in_array( $target_value, $allowed_targets, true ) ) {
171 $html->remove_attribute( $attr_name );
172 } elseif ( '_blank' === $target_value ) {
173 // When target is "_blank", ensure rel attribute has noopener and noreferrer.
174 $current_rel = $html->get_attribute( 'rel' );
175 $rel_value = is_string( $current_rel ) ? $current_rel : null;
176 $normalized_rel = self::normalize_rel_attribute( $rel_value, true );
177 $html->set_attribute( 'rel', $normalized_rel );
178 }
179 break;
180 case 'rel':
181 // Normalize rel attribute: lowercase, deduplicate, preserve safe tokens.
182 $rel_value = is_string( $attr_value ) ? $attr_value : null;
183 $normalized_rel = self::normalize_rel_attribute( $rel_value, false );
184 if ( empty( $normalized_rel ) ) {
185 $html->remove_attribute( $attr_name );
186 } else {
187 $html->set_attribute( $attr_name, $normalized_rel );
188 }
189 break;
190 case 'style':
191 // Only allow safe CSS properties for typography and basic styling.
192 $safe_properties = self::get_safe_css_properties();
193 $sanitized_styles = array();
194 $style_parts = explode( ';', (string) $attr_value );
195 foreach ( $style_parts as $style_part ) {
196 $style_part = trim( $style_part );
197 if ( empty( $style_part ) ) {
198 continue;
199 }
200 $property_parts = explode( ':', $style_part, 2 );
201 if ( count( $property_parts ) !== 2 ) {
202 continue;
203 }
204 $property = trim( strtolower( $property_parts[0] ) );
205 $value = trim( $property_parts[1] );
206 // Only allow safe properties.
207 if ( in_array( $property, $safe_properties, true ) ) {
208 // Use centralized CSS value sanitization.
209 $sanitized_value = self::sanitize_css_value( $value );
210 if ( ! empty( $sanitized_value ) ) {
211 $sanitized_styles[] = $property . ': ' . $sanitized_value;
212 }
213 }
214 }
215 if ( empty( $sanitized_styles ) ) {
216 $html->remove_attribute( $attr_name );
217 } else {
218 $html->set_attribute( $attr_name, implode( '; ', $sanitized_styles ) );
219 }
220 break;
221 case 'class':
222 // Only allow alphanumeric characters, hyphens, and underscores.
223 if ( ! preg_match( '/^[a-zA-Z0-9\s\-_]+$/', (string) $attr_value ) ) {
224 $html->remove_attribute( $attr_name );
225 }
226 break;
227 case 'data-type':
228 case 'data-id':
229 // Only allow alphanumeric characters, hyphens, and underscores.
230 if ( ! preg_match( '/^[a-zA-Z0-9\-_]+$/', (string) $attr_value ) ) {
231 $html->remove_attribute( $attr_name );
232 }
233 break;
234 default:
235 // Handle data-* attributes with strict validation.
236 if ( str_starts_with( $attr_name, 'data-' ) ) {
237 if ( ! preg_match( '/^[a-zA-Z0-9\-_]+$/', (string) $attr_value ) ) {
238 $html->remove_attribute( $attr_name );
239 }
240 break;
241 }
242 // Default deny policy: Remove any attribute not explicitly allowed.
243 $html->remove_attribute( $attr_name );
244 break;
245 }
246 }
247 public static function get_safe_css_properties(): array {
248 return array(
249 'color',
250 'background-color',
251 'font-family',
252 'font-size',
253 'font-weight',
254 'font-style',
255 'text-decoration',
256 'text-align',
257 'line-height',
258 'letter-spacing',
259 'text-transform',
260 );
261 }
262 public static function get_caption_css_properties(): array {
263 return array(
264 'font-family',
265 'font-size',
266 'font-weight',
267 'font-style',
268 'text-decoration',
269 'line-height',
270 'letter-spacing',
271 'text-transform',
272 );
273 }
274 public static function validate_container_attributes( string $container_html ): bool {
275 // Use WP_HTML_Tag_Processor to validate container attributes.
276 $html = new \WP_HTML_Tag_Processor( $container_html );
277 if ( ! $html->next_tag() ) {
278 return false;
279 }
280 // Get all attributes and validate each one using our existing validation logic.
281 $attributes = $html->get_attribute_names_with_prefix( '' );
282 if ( is_array( $attributes ) ) {
283 foreach ( $attributes as $attr_name ) {
284 // Use the same validation logic as validate_caption_attribute for consistency.
285 $attr_value = $html->get_attribute( $attr_name );
286 if ( null === $attr_value ) {
287 continue;
288 }
289 // Block event handlers immediately.
290 if ( str_starts_with( $attr_name, 'on' ) ) {
291 return false;
292 }
293 // Apply the same validation rules as caption attributes.
294 // Create a temporary processor to test validation.
295 $escaped_value = htmlspecialchars( (string) $attr_value, ENT_QUOTES, 'UTF-8' );
296 $temp_html = new \WP_HTML_Tag_Processor( '<span ' . $attr_name . '="' . $escaped_value . '">test</span>' );
297 if ( $temp_html->next_tag() ) {
298 $original_value = $temp_html->get_attribute( $attr_name );
299 self::validate_caption_attribute( $temp_html, $attr_name );
300 $validated_value = $temp_html->get_attribute( $attr_name );
301 // If attribute was removed during validation, container is unsafe.
302 if ( null !== $original_value && null === $validated_value ) {
303 return false;
304 }
305 }
306 }
307 }
308 return true;
309 }
310 public static function sanitize_caption_html( string $caption_html ): string {
311 // If no HTML tags, return as-is.
312 if ( false === strpos( $caption_html, '<' ) ) {
313 return $caption_html;
314 }
315 foreach ( array( 'script', 'style', 'iframe', 'object', 'embed', 'form', 'input', 'button' ) as $tag ) {
316 $result = preg_replace( '/<' . $tag . '\b[^>]*>.*?<\/' . $tag . '>/is', '', $caption_html );
317 // A caption long enough to exhaust PCRE's limits keeps whatever the pass
318 // managed to remove. wp_kses() below still drops the element itself, so
319 // only the text that was inside it is left behind.
320 if ( null !== $result ) {
321 $caption_html = $result;
322 }
323 }
324 $caption_html = wp_kses( $caption_html, self::get_allowed_caption_html(), array( 'http', 'https', 'mailto', 'tel' ) );
325 $html = new \WP_HTML_Tag_Processor( $caption_html );
326 while ( $html->next_tag() ) {
327 $attributes = $html->get_attribute_names_with_prefix( '' );
328 if ( is_array( $attributes ) ) {
329 foreach ( $attributes as $attr_name ) {
330 self::validate_caption_attribute( $html, $attr_name );
331 }
332 }
333 }
334 return $html->get_updated_html();
335 }
336 private static function get_allowed_caption_html(): array {
337 $common_attributes = array(
338 'class' => true,
339 'style' => true,
340 'data-*' => true,
341 );
342 return array(
343 'a' => array_merge(
344 $common_attributes,
345 array(
346 'href' => true,
347 'target' => true,
348 'rel' => true,
349 )
350 ),
351 'br' => $common_attributes,
352 'em' => $common_attributes,
353 'kbd' => $common_attributes,
354 'mark' => $common_attributes,
355 's' => $common_attributes,
356 'span' => $common_attributes,
357 'strong' => $common_attributes,
358 'sub' => $common_attributes,
359 'sup' => $common_attributes,
360 );
361 }
362 public static function sanitize_image_html( string $image_html ): string {
363 // If no HTML tags, return as-is.
364 if ( false === strpos( $image_html, '<' ) ) {
365 return $image_html;
366 }
367 // Extract img tag using regex for reliable processing.
368 if ( ! preg_match( '/<img[^>]*>/i', $image_html, $matches ) ) {
369 return $image_html;
370 }
371 $img_tag = $matches[0];
372 $sanitized_attributes = array();
373 $has_src = false;
374 // Extract and sanitize individual attributes using WP_HTML_Tag_Processor for attribute processing.
375 $html = new \WP_HTML_Tag_Processor( $img_tag );
376 if ( $html->next_tag() ) {
377 $attributes = $html->get_attribute_names_with_prefix( '' );
378 if ( is_array( $attributes ) ) {
379 foreach ( $attributes as $attr_name ) {
380 $attr_value = $html->get_attribute( $attr_name );
381 // Sanitize specific attributes.
382 switch ( $attr_name ) {
383 case 'src':
384 // Sanitize image source URL.
385 $sanitized_src = esc_url( (string) $attr_value );
386 if ( ! empty( $sanitized_src ) ) {
387 $sanitized_attributes[] = $attr_name . '="' . $sanitized_src . '"';
388 $has_src = true;
389 }
390 break;
391 case 'alt':
392 case 'width':
393 case 'height':
394 // Sanitize text attributes.
395 $sanitized_attributes[] = $attr_name . '="' . esc_attr( (string) $attr_value ) . '"';
396 break;
397 case 'class':
398 // Clean CSS classes.
399 $cleaned_classes = self::clean_css_classes( (string) $attr_value );
400 if ( ! empty( $cleaned_classes ) ) {
401 $sanitized_attributes[] = $attr_name . '="' . esc_attr( $cleaned_classes ) . '"';
402 }
403 break;
404 case 'style':
405 // Sanitize inline styles - only allow safe properties for email rendering.
406 $sanitized_styles = self::sanitize_image_styles( (string) $attr_value );
407 if ( ! empty( $sanitized_styles ) ) {
408 $sanitized_attributes[] = $attr_name . '="' . esc_attr( $sanitized_styles ) . '"';
409 }
410 break;
411 }
412 }
413 }
414 }
415 // If no valid src attribute, return empty string.
416 if ( ! $has_src ) {
417 return '';
418 }
419 // Rebuild the img tag with sanitized attributes.
420 if ( empty( $sanitized_attributes ) ) {
421 return '';
422 }
423 return '<img ' . implode( ' ', $sanitized_attributes ) . '>';
424 }
425 public static function extract_url_from_text( string $text ): string {
426 if ( preg_match( '/(?<![a-zA-Z0-9.-])https?:\/\/[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}[a-zA-Z0-9\/?=&%_.~+#-]*(?![a-zA-Z0-9._~+#-])/', $text, $matches ) ) {
427 return $matches[0];
428 }
429 return '';
430 }
431 private static function sanitize_image_styles( string $style_value ): string {
432 $sanitized_styles = array();
433 $style_parts = explode( ';', $style_value );
434 foreach ( $style_parts as $style_part ) {
435 $style_part = trim( $style_part );
436 if ( empty( $style_part ) ) {
437 continue;
438 }
439 $property_parts = explode( ':', $style_part, 2 );
440 if ( count( $property_parts ) !== 2 ) {
441 continue;
442 }
443 $property = trim( strtolower( $property_parts[0] ) );
444 $value = trim( $property_parts[1] );
445 // Allow safe CSS properties for images in email rendering.
446 $safe_properties = array( 'width', 'height', 'max-width', 'max-height', 'display', 'margin', 'padding', 'border', 'border-radius' );
447 if ( in_array( $property, $safe_properties, true ) ) {
448 $sanitized_value = self::sanitize_css_value( $value );
449 if ( ! empty( $sanitized_value ) ) {
450 $sanitized_styles[] = $property . ': ' . $sanitized_value;
451 }
452 }
453 }
454 return implode( '; ', $sanitized_styles );
455 }
456 }
457