PluginProbe ʕ •ᴥ•ʔ
MailPoet – Newsletters, Email Marketing, and Automation / 5.34.3
MailPoet – Newsletters, Email Marketing, and Automation v5.34.3
5.36.1 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 1 year ago class-html-processing-helper.php 6 months ago class-social-links-helper.php 1 year ago class-styles-helper.php 6 months ago class-table-wrapper-helper.php 1 year ago index.php 1 year ago
class-html-processing-helper.php
456 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 // Remove dangerous content: script, style, and other executable elements.
316 $result = preg_replace( '/<(script|style|iframe|object|embed|form|input|button)\b[^>]*>.*?<\/\1>/is', '', $caption_html );
317 if ( null === $result ) {
318 $caption_html = '';
319 } else {
320 $caption_html = $result;
321 }
322 // Use a more conservative approach - only validate attributes, don't modify tags.
323 $allowed_tags = array( 'strong', 'em', 'a', 'mark', 'kbd', 's', 'sub', 'sup', 'span', 'br' );
324 $html = new \WP_HTML_Tag_Processor( $caption_html );
325 // First pass: Process attributes for allowed tags only.
326 while ( $html->next_tag() ) {
327 $tag_name = $html->get_tag();
328 // Skip processing for disallowed tags.
329 if ( ! in_array( $tag_name, $allowed_tags, true ) ) {
330 continue;
331 }
332 // Only process attributes for allowed tags.
333 $attributes = $html->get_attribute_names_with_prefix( '' );
334 if ( is_array( $attributes ) ) {
335 foreach ( $attributes as $attr_name ) {
336 // Validate and sanitize each attribute individually.
337 self::validate_caption_attribute( $html, $attr_name );
338 }
339 }
340 }
341 // Second pass: Remove disallowed tags using a simple regex approach.
342 $final_html = $html->get_updated_html();
343 // Create a regex pattern to match disallowed tags.
344 $allowed_tags_pattern = implode( '|', array_map( 'preg_quote', $allowed_tags ) );
345 // Remove disallowed opening and closing tags, keeping only their content.
346 $result = preg_replace( '/<(?!(?:' . $allowed_tags_pattern . ')\b)[^>]*>(.*?)<\/(?!(?:' . $allowed_tags_pattern . ')\b)[^>]*>/s', '$1', $final_html );
347 if ( null === $result ) {
348 $final_html = '';
349 } else {
350 $final_html = $result;
351 }
352 // Remove disallowed self-closing tags.
353 $result = preg_replace( '/<(?!(?:' . $allowed_tags_pattern . ')\b)[^>]*\/>/s', '', $final_html );
354 if ( null === $result ) {
355 $final_html = '';
356 } else {
357 $final_html = $result;
358 }
359 return $final_html;
360 }
361 public static function sanitize_image_html( string $image_html ): string {
362 // If no HTML tags, return as-is.
363 if ( false === strpos( $image_html, '<' ) ) {
364 return $image_html;
365 }
366 // Extract img tag using regex for reliable processing.
367 if ( ! preg_match( '/<img[^>]*>/i', $image_html, $matches ) ) {
368 return $image_html;
369 }
370 $img_tag = $matches[0];
371 $sanitized_attributes = array();
372 $has_src = false;
373 // Extract and sanitize individual attributes using WP_HTML_Tag_Processor for attribute processing.
374 $html = new \WP_HTML_Tag_Processor( $img_tag );
375 if ( $html->next_tag() ) {
376 $attributes = $html->get_attribute_names_with_prefix( '' );
377 if ( is_array( $attributes ) ) {
378 foreach ( $attributes as $attr_name ) {
379 $attr_value = $html->get_attribute( $attr_name );
380 // Sanitize specific attributes.
381 switch ( $attr_name ) {
382 case 'src':
383 // Sanitize image source URL.
384 $sanitized_src = esc_url( (string) $attr_value );
385 if ( ! empty( $sanitized_src ) ) {
386 $sanitized_attributes[] = $attr_name . '="' . $sanitized_src . '"';
387 $has_src = true;
388 }
389 break;
390 case 'alt':
391 case 'width':
392 case 'height':
393 // Sanitize text attributes.
394 $sanitized_attributes[] = $attr_name . '="' . esc_attr( (string) $attr_value ) . '"';
395 break;
396 case 'class':
397 // Clean CSS classes.
398 $cleaned_classes = self::clean_css_classes( (string) $attr_value );
399 if ( ! empty( $cleaned_classes ) ) {
400 $sanitized_attributes[] = $attr_name . '="' . esc_attr( $cleaned_classes ) . '"';
401 }
402 break;
403 case 'style':
404 // Sanitize inline styles - only allow safe properties for email rendering.
405 $sanitized_styles = self::sanitize_image_styles( (string) $attr_value );
406 if ( ! empty( $sanitized_styles ) ) {
407 $sanitized_attributes[] = $attr_name . '="' . esc_attr( $sanitized_styles ) . '"';
408 }
409 break;
410 }
411 }
412 }
413 }
414 // If no valid src attribute, return empty string.
415 if ( ! $has_src ) {
416 return '';
417 }
418 // Rebuild the img tag with sanitized attributes.
419 if ( empty( $sanitized_attributes ) ) {
420 return '';
421 }
422 return '<img ' . implode( ' ', $sanitized_attributes ) . '>';
423 }
424 public static function extract_url_from_text( string $text ): string {
425 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 ) ) {
426 return $matches[0];
427 }
428 return '';
429 }
430 private static function sanitize_image_styles( string $style_value ): string {
431 $sanitized_styles = array();
432 $style_parts = explode( ';', $style_value );
433 foreach ( $style_parts as $style_part ) {
434 $style_part = trim( $style_part );
435 if ( empty( $style_part ) ) {
436 continue;
437 }
438 $property_parts = explode( ':', $style_part, 2 );
439 if ( count( $property_parts ) !== 2 ) {
440 continue;
441 }
442 $property = trim( strtolower( $property_parts[0] ) );
443 $value = trim( $property_parts[1] );
444 // Allow safe CSS properties for images in email rendering.
445 $safe_properties = array( 'width', 'height', 'max-width', 'max-height', 'display', 'margin', 'padding', 'border', 'border-radius' );
446 if ( in_array( $property, $safe_properties, true ) ) {
447 $sanitized_value = self::sanitize_css_value( $value );
448 if ( ! empty( $sanitized_value ) ) {
449 $sanitized_styles[] = $property . ': ' . $sanitized_value;
450 }
451 }
452 }
453 return implode( '; ', $sanitized_styles );
454 }
455 }
456