PluginProbe ʕ •ᴥ•ʔ
WooCommerce / 10.9.4
WooCommerce v10.9.4
10.9.4 10.9.3 10.9.2 10.9.1 10.9.0 10.9.0-rc.1 10.9.0-beta.2 10.9.0-beta.1 10.8.1 10.8.0 10.8.0-rc.1 10.8.0-beta.2 10.8.0-beta.1 7.8.0-beta.1 7.8.0-beta.2 7.8.0-rc.1 7.8.0-rc.2 7.8.1 7.8.2 7.8.3 7.8.4 7.9.0 7.9.0-beta.1 7.9.0-beta.2 7.9.0-rc.2 7.9.0-rc.3 7.9.1 7.9.2 8.0.0 8.0.0-beta.1 8.0.0-beta.2 8.0.0-rc.1 8.0.0-rc.2 8.0.1 8.0.2 8.0.3 8.0.4 8.0.5 8.1.0 8.1.0-beta.1 8.1.0-rc.1 8.1.0-rc.2 8.1.1 8.1.2 8.1.3 8.1.4 8.2.0 8.2.0-beta.1 8.2.0-rc.1 8.2.0-rc.2 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.3.0 8.3.0-beta.1 8.3.0-rc.1 8.3.0-rc.2 8.3.1 8.3.2 8.3.3 8.3.4 8.4.0 8.4.0-beta.1 8.4.0-rc.1 8.4.1 8.4.2 8.4.3 8.5.0 8.5.0-beta.1 8.5.0-rc.1 8.5.1 8.5.2 8.5.3 8.5.4 8.5.5 8.6.0 8.6.0-beta.1 8.6.0-rc.1 8.6.1 8.6.2 8.6.3 8.6.4 8.7.0 8.7.0-beta.1 8.7.0-beta.2 8.7.0-rc.1 8.7.1 8.7.2 8.7.3 8.8.0 8.8.0-beta.1 8.8.0-rc.1 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.8.6 8.8.7 8.9.0 8.9.0-beta.1 8.9.0-rc.1 8.9.1 8.9.2 8.9.3 8.9.4 8.9.5 9.0.0 9.0.0-beta.1 9.0.0-beta.2 9.0.0-rc.1 9.0.1 9.0.2 9.0.3 9.0.4 9.1.0 9.1.0-beta.1 9.1.0-rc.1 9.1.1 9.1.2 9.1.3 9.1.4 9.1.5 9.1.6 9.2.0 9.2.0-beta.1 9.2.0-rc.1 9.2.1 9.2.2 9.2.3 9.2.4 9.2.5 9.3.0 9.3.0-beta.1 9.3.0-rc.1 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.3.6 9.4.0 9.4.0-beta.1 9.4.0-beta.2 9.4.0-rc.1 9.4.0-rc.2 9.4.0-rc.3 9.4.0-rc.4 9.4.1 9.4.2 9.4.3 9.4.4 9.4.5 9.5.0 9.5.0-beta.1 9.5.0-beta.2 9.5.0-rc.1 9.5.1 9.5.2 9.5.3 9.5.4 9.6.0 9.6.0-beta.1 9.6.0-beta.2 9.6.0-rc.1 9.6.1 9.6.2 9.6.3 9.6.4 9.7.0 9.7.0-beta.1 9.7.0-rc.1 9.7.1 9.7.2 9.7.3 9.8.0 9.8.0-beta.1 9.8.0-rc.1 9.8.1 9.8.2 9.8.3 9.8.4 9.8.5 9.8.6 9.8.7 9.9.0 9.9.0-beta.1 9.9.0-rc.1 9.9.1 9.9.2 9.9.3 9.9.4 9.9.5 9.9.6 9.9.7 3.7.3 7.1.2 3.8.0 7.2.0 3.8.0-beta.1 7.2.0-beta.1 3.8.0-rc.1 7.2.0-beta.2 3.8.0-rc.2 7.2.0-rc.1 3.8.1 7.2.0-rc.2 3.8.2 7.2.1 3.8.3 7.2.2 3.9.0 7.2.3 3.9.0-beta.1 7.2.4 3.9.0-beta.2 7.3.0 3.9.0-rc.1 7.3.0-beta.1 3.9.0-rc.2 7.3.0-beta.2 3.9.0-rc.3 7.3.0-rc.1 3.9.0-rc.4 7.3.0-rc.2 3.9.1 7.3.1 3.9.2 7.4.0 3.9.3 7.4.0-beta.1 3.9.4 7.4.0-beta.2 3.9.5 7.4.0-rc.1 4.0.0 7.4.0-rc.2 4.0.0-beta.1 7.4.1 4.0.0-rc.1 7.4.2 4.0.0-rc.2 7.5.0 4.0.1 7.5.0-beta.1 4.0.2 7.5.0-beta.2 4.0.3 7.5.0-rc.1 4.0.4 7.5.1 4.1.0 7.5.2 4.1.0-beta.1 7.6.0 4.1.0-beta.2 7.6.0-beta.1 4.1.0-rc.1 7.6.0-beta.2 4.1.0-rc.2 7.6.0-rc.1 4.1.1 7.6.0-rc.2 4.1.2 7.6.0-rc.3 4.1.3 7.6.1 4.1.4 7.6.2 4.2.0 7.7.0 4.2.0-RC.1 7.7.0-beta.1 4.2.0-RC.2 7.7.0-beta.2 4.2.0-beta.1 7.7.0-rc.1 4.2.1 7.7.1 4.2.2 7.7.2 4.2.3 7.7.3 4.2.4 7.8.0 4.2.5 4.3.0 4.3.0-beta.1 4.3.0-rc.1 4.3.0-rc.2 4.3.0-rc.3 4.3.1 4.3.2 4.3.3 4.3.4 4.3.5 4.3.6 4.4.0 4.4.0-beta.1 4.4.0-rc.1 4.4.1 4.4.2 4.4.3 4.4.4 4.5.0 4.5.0-beta.1 4.5.0-rc.1 4.5.0-rc.3 4.5.1 4.5.2 4.5.3 4.5.4 4.5.5 4.6.0 4.6.0-beta.1 4.6.0-rc.1 4.6.1 4.6.2 4.6.3 4.6.4 4.6.5 4.7.0 4.7.0-beta.1 4.7.0-beta.2 4.7.0-rc.1 4.7.1 4.7.1-beta.1 4.7.2 4.7.3 4.7.4 4.8.0 4.8.0-beta.1 4.8.0-rc.1 4.8.0-rc.2 4.8.1 4.8.2 4.8.3 4.9.0 4.9.0-beta.1 4.9.0-rc.1 4.9.0-rc.2 4.9.1 4.9.2 4.9.3 4.9.4 4.9.5 5.0.0 5.0.0-beta.1 5.0.0-beta.2 5.0.0-rc.1 5.0.0-rc.2 5.0.0-rc.3 5.0.1 5.0.2 5.0.3 5.1.0 5.1.0-beta.1 5.1.0-rc.1 trunk 5.1.1 10.0.0 5.1.2 10.0.0-rc.1 5.1.3 10.0.0-rc.2 5.2.0 10.0.1 5.2.0-beta.1 10.0.2 5.2.0-rc.1 10.0.3 5.2.0-rc.2 10.0.4 5.2.1 10.0.5 5.2.2 10.0.6 5.2.3 10.1.0 5.2.4 10.1.0-rc.1 5.2.5 10.1.0-rc.2 5.3.0 10.1.0-rc.3 5.3.0-beta.1 10.1.0-rc.4 5.3.0-rc.1 10.1.1 5.3.0-rc.2 10.1.2 5.3.1 10.1.3 5.3.2 10.1.4 5.3.3 10.2.0 5.4.0 10.2.0-beta.1 5.4.0-beta.1 10.2.0-beta.2 5.4.0-rc.1 10.2.0-rc.1 5.4.1 10.2.1 5.4.2 10.2.2 5.4.3 10.2.3 5.4.4 10.2.4 5.4.5 10.3.0 5.5.0 10.3.0-beta.1 5.5.0-beta.1 10.3.0-beta.2 5.5.0-rc.1 10.3.0-rc.1 5.5.0-rc.2 10.3.0-rc.2 5.5.1 10.3.1 5.5.2 10.3.2 5.5.3 10.3.3 5.5.4 10.3.4 5.5.5 10.3.5 5.6.0 10.3.6 5.6.0-beta.1 10.3.7 5.6.0-rc.1 10.3.8 5.6.0-rc.2 10.4.0 5.6.1 10.4.0-beta.1 5.6.2 10.4.0-beta.2 5.6.3 10.4.0-rc.1 5.7.0 10.4.1 5.7.0-beta.1 10.4.2 5.7.0-rc.1 10.4.3 5.7.1 10.4.4 5.7.2 10.5.0 5.7.3 10.5.0-beta.1 5.8.0 10.5.0-beta.2 5.8.0-beta.1 10.5.0-rc.1 5.8.0-beta.2 10.5.0-rc.2 5.8.0-rc.1 10.5.0-rc.3 5.8.1 10.5.1 5.8.2 10.5.2 5.9.0 10.5.3 5.9.0-beta.1 10.6.0 5.9.0-rc.1 10.6.0-beta.1 5.9.0-rc.2 10.6.0-beta.2 5.9.1 10.6.0-rc.1 5.9.2 10.6.1 6.0.0 10.6.2 6.0.0-beta.1 10.7.0 6.0.0-rc.1 10.7.0-beta.1 6.0.1 10.7.0-beta.2 6.0.2 10.7.0-rc.1 6.1.0 3.0.0 6.1.0-beta.1 3.0.1 6.1.0-rc.1 3.0.2 6.1.0-rc.2 3.0.3 6.1.1 3.0.4 6.1.2 3.0.5 6.1.3 3.0.6 6.2.0 3.0.7 6.2.0-beta.1 3.0.8 6.2.0-rc.1 3.0.9 6.2.0-rc.2 3.1.0 6.2.1 3.1.1 6.2.2 3.1.2 6.2.3 3.2.0 6.3.0 3.2.1 6.3.0-beta.1 3.2.2 6.3.0-rc.1 3.2.3 6.3.0-rc.2 3.2.4 6.3.1 3.2.5 6.3.2 3.2.6 6.4.0 3.3.0 6.4.0-beta.1 3.3.1 6.4.0-rc.1 3.3.2 6.4.1 3.3.2-rc.1 6.4.2 3.3.3 6.5.0 3.3.4 6.5.0-beta.1 3.3.5 6.5.0-rc.1 3.3.6 6.5.0-rc.2 3.4.0 6.5.1 3.4.0-beta.1 6.5.2 3.4.0-rc.2 6.6.0 3.4.1 6.6.0-beta.1 3.4.2 6.6.0-rc.1 3.4.3 6.6.0-rc.2 3.4.4 6.6.1 3.4.5 6.6.2 3.4.6 6.7.0 3.4.7 6.7.0-beta.1 3.4.8 6.7.0-beta.2 3.5.0 6.7.0-rc.1 3.5.0-beta.1 6.7.1 3.5.0-rc.1 6.8.0 3.5.0-rc.2 6.8.0-beta.1 3.5.1 6.8.0-beta.2 3.5.10 6.8.0-rc.1 3.5.2 6.8.1 3.5.3 6.8.2 3.5.4 6.8.3 3.5.5 6.9.0 3.5.6 6.9.0-beta.1 3.5.7 6.9.0-beta.2 3.5.8 6.9.0-rc.1 3.5.9 6.9.1 3.6.0 6.9.2 3.6.0-beta.1 6.9.3 3.6.0-rc.1 6.9.4 3.6.0-rc.2 6.9.5 3.6.0-rc.3 7.0.0 3.6.1 7.0.0-beta.1 3.6.2 7.0.0-beta.2 3.6.3 7.0.0-beta.3 3.6.4 7.0.0-rc.1 3.6.5 7.0.0-rc.2 3.6.6 7.0.1 3.6.7 7.0.2 3.7.0 7.1.0 3.7.0-beta.1 7.1.0-beta.1 3.7.0-rc.1 7.1.0-beta.2 3.7.0-rc.2 7.1.0-rc.1 3.7.1 7.1.0-rc.2 3.7.2 7.1.1
woocommerce / packages / email-editor / src / Integrations / Utils / class-html-processing-helper.php
woocommerce / packages / email-editor / src / Integrations / Utils Last commit date
class-dom-document-helper.php 1 year ago class-html-processing-helper.php 4 months ago class-social-links-helper.php 1 year ago class-styles-helper.php 4 months ago class-table-wrapper-helper.php 11 months ago
class-html-processing-helper.php
628 lines
1 <?php
2 /**
3 * This file is part of the WooCommerce Email Editor package
4 *
5 * @package Automattic\WooCommerce\EmailEditor
6 */
7
8 declare( strict_types = 1 );
9 namespace Automattic\WooCommerce\EmailEditor\Integrations\Utils;
10
11 /**
12 * Helper class for HTML processing and manipulation.
13 */
14 class Html_Processing_Helper {
15 /**
16 * Clean CSS classes by removing background and border related classes.
17 *
18 * @param string $classes CSS classes to clean.
19 * @return string Cleaned CSS classes.
20 */
21 public static function clean_css_classes( string $classes ): string {
22 // Limit input length to prevent DoS attacks.
23 if ( strlen( $classes ) > 1000 ) {
24 $classes = substr( $classes, 0, 1000 );
25 }
26
27 // Remove generic background classes but keep specific color classes.
28 $result = preg_replace( '/\bhas-background\b/', '', $classes );
29 if ( null === $result ) {
30 $classes = '';
31 } else {
32 $classes = $result;
33 }
34
35 // Remove border classes.
36 $result = preg_replace( '/\bhas-[a-z-]*border[a-z-]*\b/', '', $classes );
37 if ( null === $result ) {
38 $classes = '';
39 } else {
40 $classes = $result;
41 }
42
43 $result = preg_replace( '/\b[a-z-]+-border-[a-z-]+\b/', '', $classes );
44 if ( null === $result ) {
45 $classes = '';
46 } else {
47 $classes = $result;
48 }
49
50 // Clean up multiple spaces.
51 $result = preg_replace( '/\s+/', ' ', $classes );
52 if ( null === $result ) {
53 $classes = '';
54 } else {
55 $classes = $result;
56 }
57
58 return trim( $classes );
59 }
60
61 /**
62 * Sanitize CSS value to prevent injection attacks.
63 *
64 * @param string $value CSS value to sanitize.
65 * @return string Sanitized CSS value or empty string if invalid.
66 */
67 public static function sanitize_css_value( string $value ): string {
68 // Remove dangerous script injection characters (angle brackets) but preserve quotes for CSS strings.
69 $result = preg_replace( '/[<>]/', '', $value );
70 if ( null === $result ) {
71 $value = '';
72 } else {
73 $value = $result;
74 }
75
76 // Remove dangerous CSS functions and expressions.
77 $dangerous_patterns = array(
78 '/expression\s*\(/i',
79 '/url\s*\(\s*javascript\s*:/i',
80 '/url\s*\(\s*data\s*:/i',
81 '/url\s*\(\s*vbscript\s*:/i',
82 '/import\s*\(/i',
83 '/behavior\s*:/i',
84 '/binding\s*:/i',
85 '/filter\s*:/i',
86 '/progid\s*:/i',
87 );
88
89 foreach ( $dangerous_patterns as $pattern ) {
90 if ( preg_match( $pattern, $value ) ) {
91 return '';
92 }
93 }
94
95 return trim( $value );
96 }
97
98 /**
99 * Sanitize dimension value to ensure it's a valid CSS dimension.
100 *
101 * Supports numeric values (converted to px) and standard CSS units.
102 *
103 * @param mixed $value The dimension value to sanitize.
104 * @return string Sanitized dimension value or empty string if invalid.
105 */
106 public static function sanitize_dimension_value( $value ): string {
107 if ( ! is_string( $value ) && ! is_numeric( $value ) ) {
108 return '';
109 }
110
111 $value = (string) $value;
112
113 // If it's just a number, assume pixels.
114 if ( is_numeric( $value ) ) {
115 $value = $value . 'px';
116 }
117
118 // Use existing CSS value sanitization for security.
119 $sanitized_value = self::sanitize_css_value( $value );
120
121 // Additional validation for dimension-specific units.
122 if ( ! empty( $sanitized_value ) && preg_match( '/^(\d+(?:\.\d+)?)(px|em|rem|%|vh|vw|ex|ch|in|cm|mm|pt|pc)$/', $sanitized_value ) ) {
123 return $sanitized_value;
124 }
125
126 return '';
127 }
128
129 /**
130 * Sanitize color value to ensure it's a valid color format.
131 *
132 * Supports hex colors, rgb/rgba, hsl/hsla, named colors, and CSS variables.
133 *
134 * @param string $color The color value to sanitize.
135 * @return string Sanitized color value or safe default if invalid.
136 */
137 public static function sanitize_color( string $color ): string {
138 // Remove any whitespace.
139 $color = trim( $color );
140
141 // Check if it's a valid hex color (#fff, #ffffff, #ffffffff).
142 if ( preg_match( '/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/', $color ) ) {
143 return strtolower( $color );
144 }
145
146 // Check for rgb/rgba colors.
147 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 ) ) {
148 return $color;
149 }
150
151 // Check for hsl/hsla colors.
152 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 ) ) {
153 return $color;
154 }
155
156 // Check for named colors and other valid CSS color values.
157 // We use a permissive approach: accept any string that doesn't contain dangerous characters
158 // and let the CSS engine handle the actual validation.
159 if ( preg_match( '/^[a-zA-Z][a-zA-Z0-9-]*$/', $color ) && ! preg_match( '/^(expression|javascript|vbscript|data|import|behavior|binding|filter|progid)/i', $color ) ) {
160 return strtolower( $color );
161 }
162
163 // Check if it's a CSS variable (var(--variable-name)).
164 if ( preg_match( '/^var\(--[a-zA-Z0-9\-_]+\)$/', $color ) ) {
165 return $color;
166 }
167
168 // If not a valid color format, return a safe default.
169 return '#000000';
170 }
171
172 /**
173 * Normalize rel attribute by lowercasing, deduplicating tokens, and ensuring required tokens.
174 *
175 * @param string|null $rel_value Current rel attribute value.
176 * @param bool $require_security_tokens Whether to require noopener and noreferrer tokens.
177 * @return string Normalized rel attribute value.
178 */
179 private static function normalize_rel_attribute( ?string $rel_value, bool $require_security_tokens = false ): string {
180 $allowed_tokens = array( 'noopener', 'noreferrer', 'nofollow', 'external' );
181 $required_tokens = $require_security_tokens ? array( 'noopener', 'noreferrer' ) : array();
182
183 // If no rel value and no required tokens, return empty.
184 if ( null === $rel_value && empty( $required_tokens ) ) {
185 return '';
186 }
187
188 // Start with required tokens.
189 $tokens = $required_tokens;
190
191 // If rel value exists, parse and normalize it.
192 if ( null !== $rel_value ) {
193 $existing_tokens = preg_split( '/\s+/', trim( $rel_value ) );
194 if ( false !== $existing_tokens ) {
195 // Normalize existing tokens: lowercase, remove empty, filter allowed.
196 $normalized_existing = array_filter(
197 array_map( 'strtolower', $existing_tokens ),
198 function ( $token ) use ( $allowed_tokens ) {
199 return ! empty( $token ) && in_array( $token, $allowed_tokens, true );
200 }
201 );
202 // Merge with required tokens, removing duplicates.
203 $tokens = array_unique( array_merge( $tokens, $normalized_existing ) );
204 }
205 }
206
207 // Return normalized rel attribute or empty string if no valid tokens.
208 return empty( $tokens ) ? '' : implode( ' ', $tokens );
209 }
210
211 /**
212 * Validate and sanitize specific caption attributes for security.
213 *
214 * @param \WP_HTML_Tag_Processor $html HTML tag processor.
215 * @param string $attr_name Attribute name to validate.
216 */
217 public static function validate_caption_attribute( \WP_HTML_Tag_Processor $html, string $attr_name ): void {
218 $attr_value = $html->get_attribute( $attr_name );
219 if ( null === $attr_value ) {
220 return;
221 }
222
223 // Block all event handler attributes (on*) - Critical security fix.
224 if ( str_starts_with( $attr_name, 'on' ) ) {
225 $html->remove_attribute( $attr_name );
226 return;
227 }
228
229 switch ( $attr_name ) {
230 case 'href':
231 // Only allow http, https, mailto, and tel protocols.
232 if ( ! preg_match( '/^(https?:\/\/|mailto:|tel:)/i', (string) $attr_value ) ) {
233 $html->remove_attribute( $attr_name );
234 break;
235 }
236
237 // Sanitize and normalize the URL using WordPress's esc_url_raw.
238 $sanitized_url = esc_url_raw( (string) $attr_value );
239 if ( empty( $sanitized_url ) ) {
240 // If esc_url_raw returns empty, the URL was invalid - remove the attribute.
241 $html->remove_attribute( $attr_name );
242 } else {
243 // Set the attribute to the sanitized/normalized value.
244 $html->set_attribute( $attr_name, $sanitized_url );
245 }
246 break;
247
248 case 'target':
249 // Allow only common safe targets.
250 $allowed_targets = array( '_blank', '_self' );
251 $target_value = strtolower( (string) $attr_value );
252 if ( ! in_array( $target_value, $allowed_targets, true ) ) {
253 $html->remove_attribute( $attr_name );
254 } elseif ( '_blank' === $target_value ) {
255 // When target is "_blank", ensure rel attribute has noopener and noreferrer.
256 $current_rel = $html->get_attribute( 'rel' );
257 $rel_value = is_string( $current_rel ) ? $current_rel : null;
258 $normalized_rel = self::normalize_rel_attribute( $rel_value, true );
259 $html->set_attribute( 'rel', $normalized_rel );
260 }
261 break;
262
263 case 'rel':
264 // Normalize rel attribute: lowercase, deduplicate, preserve safe tokens.
265 $rel_value = is_string( $attr_value ) ? $attr_value : null;
266 $normalized_rel = self::normalize_rel_attribute( $rel_value, false );
267 if ( empty( $normalized_rel ) ) {
268 $html->remove_attribute( $attr_name );
269 } else {
270 $html->set_attribute( $attr_name, $normalized_rel );
271 }
272 break;
273
274 case 'style':
275 // Only allow safe CSS properties for typography and basic styling.
276 $safe_properties = self::get_safe_css_properties();
277 $sanitized_styles = array();
278 $style_parts = explode( ';', (string) $attr_value );
279
280 foreach ( $style_parts as $style_part ) {
281 $style_part = trim( $style_part );
282 if ( empty( $style_part ) ) {
283 continue;
284 }
285
286 $property_parts = explode( ':', $style_part, 2 );
287 if ( count( $property_parts ) !== 2 ) {
288 continue;
289 }
290
291 $property = trim( strtolower( $property_parts[0] ) );
292 $value = trim( $property_parts[1] );
293
294 // Only allow safe properties.
295 if ( in_array( $property, $safe_properties, true ) ) {
296 // Use centralized CSS value sanitization.
297 $sanitized_value = self::sanitize_css_value( $value );
298 if ( ! empty( $sanitized_value ) ) {
299 $sanitized_styles[] = $property . ': ' . $sanitized_value;
300 }
301 }
302 }
303
304 if ( empty( $sanitized_styles ) ) {
305 $html->remove_attribute( $attr_name );
306 } else {
307 $html->set_attribute( $attr_name, implode( '; ', $sanitized_styles ) );
308 }
309 break;
310
311 case 'class':
312 // Only allow alphanumeric characters, hyphens, and underscores.
313 if ( ! preg_match( '/^[a-zA-Z0-9\s\-_]+$/', (string) $attr_value ) ) {
314 $html->remove_attribute( $attr_name );
315 }
316 break;
317
318 case 'data-type':
319 case 'data-id':
320 // Only allow alphanumeric characters, hyphens, and underscores.
321 if ( ! preg_match( '/^[a-zA-Z0-9\-_]+$/', (string) $attr_value ) ) {
322 $html->remove_attribute( $attr_name );
323 }
324 break;
325
326 default:
327 // Handle data-* attributes with strict validation.
328 if ( str_starts_with( $attr_name, 'data-' ) ) {
329 if ( ! preg_match( '/^[a-zA-Z0-9\-_]+$/', (string) $attr_value ) ) {
330 $html->remove_attribute( $attr_name );
331 }
332 break;
333 }
334 // Default deny policy: Remove any attribute not explicitly allowed.
335 $html->remove_attribute( $attr_name );
336 break;
337 }
338 }
339
340 /**
341 * Get list of safe CSS properties for typography and basic styling.
342 *
343 * @return array Array of safe CSS property names.
344 */
345 public static function get_safe_css_properties(): array {
346 return array(
347 'color',
348 'background-color',
349 'font-family',
350 'font-size',
351 'font-weight',
352 'font-style',
353 'text-decoration',
354 'text-align',
355 'line-height',
356 'letter-spacing',
357 'text-transform',
358 );
359 }
360
361 /**
362 * Get list of safe CSS properties for caption typography (excludes background properties).
363 *
364 * @return array Array of safe CSS property names for captions.
365 */
366 public static function get_caption_css_properties(): array {
367 return array(
368 'font-family',
369 'font-size',
370 'font-weight',
371 'font-style',
372 'text-decoration',
373 'line-height',
374 'letter-spacing',
375 'text-transform',
376 );
377 }
378
379 /**
380 * Validate HTML container attributes for security before content extraction.
381 * This method checks if a container element (like figcaption, span) has safe attributes.
382 *
383 * @param string $container_html Full container HTML (e.g., <figcaption class="...">content</figcaption>).
384 * @return bool True if container attributes are safe, false otherwise.
385 */
386 public static function validate_container_attributes( string $container_html ): bool {
387 // Use WP_HTML_Tag_Processor to validate container attributes.
388 $html = new \WP_HTML_Tag_Processor( $container_html );
389 if ( ! $html->next_tag() ) {
390 return false;
391 }
392
393 // Get all attributes and validate each one using our existing validation logic.
394 $attributes = $html->get_attribute_names_with_prefix( '' );
395 if ( is_array( $attributes ) ) {
396 foreach ( $attributes as $attr_name ) {
397 // Use the same validation logic as validate_caption_attribute for consistency.
398 $attr_value = $html->get_attribute( $attr_name );
399 if ( null === $attr_value ) {
400 continue;
401 }
402
403 // Block event handlers immediately.
404 if ( str_starts_with( $attr_name, 'on' ) ) {
405 return false;
406 }
407
408 // Apply the same validation rules as caption attributes.
409 // Create a temporary processor to test validation.
410 $escaped_value = htmlspecialchars( (string) $attr_value, ENT_QUOTES, 'UTF-8' );
411 $temp_html = new \WP_HTML_Tag_Processor( '<span ' . $attr_name . '="' . $escaped_value . '">test</span>' );
412 if ( $temp_html->next_tag() ) {
413 $original_value = $temp_html->get_attribute( $attr_name );
414 self::validate_caption_attribute( $temp_html, $attr_name );
415 $validated_value = $temp_html->get_attribute( $attr_name );
416
417 // If attribute was removed during validation, container is unsafe.
418 if ( null !== $original_value && null === $validated_value ) {
419 return false;
420 }
421 }
422 }
423 }
424
425 return true;
426 }
427
428 /**
429 * Sanitize caption HTML to allow only specific tags and attributes.
430 *
431 * @param string $caption_html Raw caption HTML.
432 * @return string Sanitized caption HTML.
433 */
434 public static function sanitize_caption_html( string $caption_html ): string {
435 // If no HTML tags, return as-is.
436 if ( false === strpos( $caption_html, '<' ) ) {
437 return $caption_html;
438 }
439
440 // Remove dangerous content: script, style, and other executable elements.
441 $result = preg_replace( '/<(script|style|iframe|object|embed|form|input|button)\b[^>]*>.*?<\/\1>/is', '', $caption_html );
442 if ( null === $result ) {
443 $caption_html = '';
444 } else {
445 $caption_html = $result;
446 }
447
448 // Use a more conservative approach - only validate attributes, don't modify tags.
449 $allowed_tags = array( 'strong', 'em', 'a', 'mark', 'kbd', 's', 'sub', 'sup', 'span', 'br' );
450
451 $html = new \WP_HTML_Tag_Processor( $caption_html );
452
453 // First pass: Process attributes for allowed tags only.
454 while ( $html->next_tag() ) {
455 $tag_name = $html->get_tag();
456
457 // Skip processing for disallowed tags.
458 if ( ! in_array( $tag_name, $allowed_tags, true ) ) {
459 continue;
460 }
461
462 // Only process attributes for allowed tags.
463 $attributes = $html->get_attribute_names_with_prefix( '' );
464 if ( is_array( $attributes ) ) {
465 foreach ( $attributes as $attr_name ) {
466 // Validate and sanitize each attribute individually.
467 self::validate_caption_attribute( $html, $attr_name );
468 }
469 }
470 }
471
472 // Second pass: Remove disallowed tags using a simple regex approach.
473 $final_html = $html->get_updated_html();
474
475 // Create a regex pattern to match disallowed tags.
476 $allowed_tags_pattern = implode( '|', array_map( 'preg_quote', $allowed_tags ) );
477
478 // Remove disallowed opening and closing tags, keeping only their content.
479 $result = preg_replace( '/<(?!(?:' . $allowed_tags_pattern . ')\b)[^>]*>(.*?)<\/(?!(?:' . $allowed_tags_pattern . ')\b)[^>]*>/s', '$1', $final_html );
480 if ( null === $result ) {
481 $final_html = '';
482 } else {
483 $final_html = $result;
484 }
485
486 // Remove disallowed self-closing tags.
487 $result = preg_replace( '/<(?!(?:' . $allowed_tags_pattern . ')\b)[^>]*\/>/s', '', $final_html );
488 if ( null === $result ) {
489 $final_html = '';
490 } else {
491 $final_html = $result;
492 }
493
494 return $final_html;
495 }
496
497 /**
498 * Sanitize image HTML while preserving necessary attributes for email rendering.
499 *
500 * @param string $image_html Raw image HTML.
501 * @return string Sanitized image HTML.
502 */
503 public static function sanitize_image_html( string $image_html ): string {
504 // If no HTML tags, return as-is.
505 if ( false === strpos( $image_html, '<' ) ) {
506 return $image_html;
507 }
508
509 // Extract img tag using regex for reliable processing.
510 if ( ! preg_match( '/<img[^>]*>/i', $image_html, $matches ) ) {
511 return $image_html;
512 }
513
514 $img_tag = $matches[0];
515 $sanitized_attributes = array();
516 $has_src = false;
517
518 // Extract and sanitize individual attributes using WP_HTML_Tag_Processor for attribute processing.
519 $html = new \WP_HTML_Tag_Processor( $img_tag );
520 if ( $html->next_tag() ) {
521 $attributes = $html->get_attribute_names_with_prefix( '' );
522 if ( is_array( $attributes ) ) {
523 foreach ( $attributes as $attr_name ) {
524 $attr_value = $html->get_attribute( $attr_name );
525
526 // Sanitize specific attributes.
527 switch ( $attr_name ) {
528 case 'src':
529 // Sanitize image source URL.
530 $sanitized_src = esc_url( (string) $attr_value );
531 if ( ! empty( $sanitized_src ) ) {
532 $sanitized_attributes[] = $attr_name . '="' . $sanitized_src . '"';
533 $has_src = true;
534 }
535 break;
536
537 case 'alt':
538 case 'width':
539 case 'height':
540 // Sanitize text attributes.
541 $sanitized_attributes[] = $attr_name . '="' . esc_attr( (string) $attr_value ) . '"';
542 break;
543
544 case 'class':
545 // Clean CSS classes.
546 $cleaned_classes = self::clean_css_classes( (string) $attr_value );
547 if ( ! empty( $cleaned_classes ) ) {
548 $sanitized_attributes[] = $attr_name . '="' . esc_attr( $cleaned_classes ) . '"';
549 }
550 break;
551
552 case 'style':
553 // Sanitize inline styles - only allow safe properties for email rendering.
554 $sanitized_styles = self::sanitize_image_styles( (string) $attr_value );
555 if ( ! empty( $sanitized_styles ) ) {
556 $sanitized_attributes[] = $attr_name . '="' . esc_attr( $sanitized_styles ) . '"';
557 }
558 break;
559 }
560 }
561 }
562 }
563
564 // If no valid src attribute, return empty string.
565 if ( ! $has_src ) {
566 return '';
567 }
568
569 // Rebuild the img tag with sanitized attributes.
570 if ( empty( $sanitized_attributes ) ) {
571 return '';
572 }
573
574 return '<img ' . implode( ' ', $sanitized_attributes ) . '>';
575 }
576
577 /**
578 * Extract the first HTTP/HTTPS URL from a text string.
579 *
580 * @param string $text Text to search for URLs.
581 * @return string Extracted URL or empty string if not found.
582 */
583 public static function extract_url_from_text( string $text ): string {
584 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 ) ) {
585 return $matches[0];
586 }
587
588 return '';
589 }
590
591 /**
592 * Sanitize inline styles for image elements - only allow safe properties for email rendering.
593 *
594 * @param string $style_value Raw style value.
595 * @return string Sanitized style value.
596 */
597 private static function sanitize_image_styles( string $style_value ): string {
598 $sanitized_styles = array();
599 $style_parts = explode( ';', $style_value );
600
601 foreach ( $style_parts as $style_part ) {
602 $style_part = trim( $style_part );
603 if ( empty( $style_part ) ) {
604 continue;
605 }
606
607 $property_parts = explode( ':', $style_part, 2 );
608 if ( count( $property_parts ) !== 2 ) {
609 continue;
610 }
611
612 $property = trim( strtolower( $property_parts[0] ) );
613 $value = trim( $property_parts[1] );
614
615 // Allow safe CSS properties for images in email rendering.
616 $safe_properties = array( 'width', 'height', 'max-width', 'max-height', 'display', 'margin', 'padding', 'border', 'border-radius' );
617 if ( in_array( $property, $safe_properties, true ) ) {
618 $sanitized_value = self::sanitize_css_value( $value );
619 if ( ! empty( $sanitized_value ) ) {
620 $sanitized_styles[] = $property . ': ' . $sanitized_value;
621 }
622 }
623 }
624
625 return implode( '; ', $sanitized_styles );
626 }
627 }
628