PluginProbe
The Innovative Form Builder – IvyForms / 0.8.2
The Innovative Form Builder – IvyForms v0.8.2
1.5.1 1.5 1.4.1 1.4 trunk 0.1.2 0.2 0.2.1 0.3 0.3.1 0.4 0.5 0.6 0.6.1 0.6.1-backup 0.6.1.1 0.7 0.8 0.8.1 0.8.2 0.9 0.9.1 1.0 1.1 1.1.1 All 27 releases
ivyforms / backend / src / Common / Sanitizer / Sanitizer.php

Sanitizer.php in The Innovative Form Builder – IvyForms 0.8.2, at backend/src/Common/Sanitizer/Sanitizer.php

907 lines 31.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * @copyright © Melograno Venture Studio. All rights reserved.
5 * @licence See COPYING.md for license details.
6 */
7
8 namespace IvyForms\Common\Sanitizer;
9
10 // phpcs:disable PSR1.Files.SideEffects
11 if (!defined('ABSPATH')) {
12 exit; // Exit if accessed directly
13 }
14
15 use IvyForms\Common\Exceptions\ForbiddenException;
16 use IvyForms\Common\Exceptions\InvalidArgumentException;
17 use IvyForms\Services\Field\FieldType;
18 use IvyForms\Services\Translations\BackendStrings;
19
20 class Sanitizer
21 {
22 /**
23 * Sanitize HTML editor content with support for inline styles (colors, formatting)
24 *
25 * @param string $content
26 *
27 * @return string
28 */
29 /**
30 * Sanitize CSS value to prevent XSS attacks while allowing colors
31 *
32 * @param string $css
33 *
34 * @return string
35 */
36 private static function sanitizeCssValue(string $css): string
37 {
38 // Remove any potential XSS vectors
39 $dangerous_patterns = [
40 '/expression\s*\(/i', // IE CSS expressions
41 '/javascript\s*:/i', // javascript: protocol
42 '/vbscript\s*:/i', // vbscript: protocol
43 '/<\s*script/i', // <script> tags
44 '/on\w+\s*=/i', // event handlers (onclick, etc)
45 '/import\s+/i', // @import
46 '/<!--/i', // HTML comments
47 '/-->/i', // HTML comments
48 '/behavior\s*:/i', // IE behaviors
49 '/-moz-binding\s*:/i', // Firefox bindings
50 '/\\\\/i', // Backslash escaping
51 ];
52
53 foreach ($dangerous_patterns as $pattern) {
54 if (preg_match($pattern, $css)) {
55 error_log('🚨 BLOCKED dangerous CSS pattern: ' . $pattern . ' in: ' . $css);
56 return ''; // Block completely if dangerous pattern found
57 }
58 }
59
60 // Validate url() values - only allow data: for images and https:/http: for safe URLs
61 $css = preg_replace_callback(
62 '/url\s*\(\s*["\']?([^"\')]+)["\']?\s*\)/i',
63 function ($matches) {
64 $url = trim($matches[1]);
65 // Allow only safe protocols
66 if (preg_match('/^(https?:|data:image\/)/i', $url)) {
67 return 'url("' . esc_url($url) . '")';
68 }
69 error_log('🚨 BLOCKED unsafe URL in CSS: ' . $url);
70 return ''; // Block unsafe URLs
71 },
72 $css
73 );
74
75 // Escape remaining content but preserve CSS functions like rgb(), rgba(), etc.
76 return esc_attr($css);
77 }
78
79 /**
80 * Sanitize HTML editor content with support for inline styles (colors, formatting)
81 *
82 * @param string $content
83 *
84 * @return string
85 */
86 public static function sanitizeEditorContent(string $content): string
87 {
88 // Extract all style attributes before wp_kses processes them
89 $style_placeholders = [];
90 $placeholder_index = 0;
91
92 $content = preg_replace_callback(
93 '/(<[^>]+)\s+style="([^"]*)"/i',
94 function ($matches) use (&$style_placeholders, &$placeholder_index) {
95 $placeholder = '___STYLE_PLACEHOLDER_' . $placeholder_index . '___';
96 // Sanitize the CSS value for security
97 $style_placeholders[$placeholder] = self::sanitizeCssValue(
98 $matches[2]
99 );
100 $placeholder_index++;
101 $html = ' data-style-placeholder="' . $placeholder . '"';
102 return $matches[1] . $html;
103 },
104 $content
105 );
106
107 // Define allowed HTML tags and attributes for the editor
108 $allowed_tags = wp_kses_allowed_html('post');
109
110 // Add necessary attributes to tags
111 $tags_with_style = [
112 'span',
113 'p',
114 'div',
115 'h1',
116 'h2',
117 'h3',
118 'h4',
119 'h5',
120 'h6',
121 'li',
122 'ul',
123 'ol',
124 'blockquote',
125 'strong',
126 'em',
127 'u',
128 'a'
129 ];
130
131 foreach ($tags_with_style as $tag) {
132 if (!isset($allowed_tags[$tag])) {
133 $allowed_tags[$tag] = [];
134 }
135 if (!is_array($allowed_tags[$tag])) {
136 $allowed_tags[$tag] = [];
137 }
138 $allowed_tags[$tag]['data-style-placeholder'] = true;
139 $allowed_tags[$tag]['class'] = true;
140 }
141
142 // Ensure 'a' tag has all necessary attributes
143 if (!isset($allowed_tags['a'])) {
144 $allowed_tags['a'] = [];
145 }
146 $allowed_tags['a']['href'] = true;
147 $allowed_tags['a']['target'] = true;
148 $allowed_tags['a']['rel'] = true;
149 $allowed_tags['a']['class'] = true;
150 $allowed_tags['a']['data-style-placeholder'] = true;
151
152 // Sanitize HTML structure (but style content is preserved in placeholders)
153 $result = wp_kses($content, $allowed_tags);
154
155 // Restore style attributes from placeholders
156 $result = preg_replace_callback(
157 '/data-style-placeholder="(___STYLE_PLACEHOLDER_\d+___)"/i',
158 function ($matches) use ($style_placeholders) {
159 $placeholder = $matches[1];
160 if (isset($style_placeholders[$placeholder])) {
161 $sanitized_style = $style_placeholders[$placeholder];
162 // Only add style attribute if there's actual content
163 if (!empty($sanitized_style)) {
164 return 'style="' . $sanitized_style . '"';
165 }
166 }
167 return '';
168 },
169 $result
170 );
171
172
173 return $result;
174 }
175
176 /**
177 * Sanitize form action buttons settings
178 *
179 * @param mixed[] $settings
180 *
181 * @return mixed[]
182 */
183 public static function sanitizeFormActionButtons(array $settings): array
184 {
185 $submitButtonSettings = $settings['submitButtonSettings'] ?? [];
186 $validPositions = ['default', 'left', 'center', 'right'];
187 $position = $submitButtonSettings['position'] ?? 'default';
188
189 // Validate position
190 if (!in_array($position, $validPositions, true)) {
191 $position = 'default';
192 }
193
194 return [
195 'submitButtonSettings' => [
196 'label' => sanitize_text_field(
197 $submitButtonSettings['label'] ?? BackendStrings::getCommonStrings()['submit']
198 ),
199 'position' => $position
200 ]
201 ];
202 }
203
204 /**
205 * Sanitize all form data
206 *
207 * @param mixed[] $data
208 *
209 * @return mixed[]
210 * @throws InvalidArgumentException
211 */
212 public static function sanitizeFormData(array $data): array
213 {
214 $fields = $data['fields'] ?? [];
215 return [
216 'id' => (int)($data['id'] ?? 0),
217 'name' => sanitize_text_field($data['name'] ?? ''),
218 'description' => sanitize_text_field($data['description'] ?? ''),
219 'showTitle' => (bool)($data['showTitle'] ?? false),
220 'published' => (bool)($data['published'] ?? false),
221 'showDescription' => (bool)($data['showDescription'] ?? false),
222 'storeEntries' => (bool)($data['storeEntries'] ?? false),
223 'fields' => self::sanitizeFields($fields),
224 'integrationSettings' => self::sanitizeIntegrationSettings(
225 $data['integrationSettings'] ?? []
226 ),
227 'formActionButtons' => self::sanitizeFormActionButtons(
228 $data['formActionButtons'] ?? []
229 )
230 ];
231 }
232 /**
233 * Sanitize all notification data
234 *
235 * @param mixed[] $data
236 *
237 * @return mixed[]
238 */
239 public static function sanitizeNotificationData(array $data): array
240 {
241 return [
242 'id' => (int)($data['id'] ?? 0),
243 'name' => sanitize_text_field($data['name'] ?? ''),
244 'sender' => self::sanitizeEmailOrPlaceholder($data['sender'] ?? ''),
245 'replyTo' => self::sanitizeEmailOrPlaceholder($data['replyTo'] ?? ''),
246 'receiver' => self::sanitizeEmailOrPlaceholder($data['receiver'] ?? ''),
247 'enabled' => (bool)($data['enabled'] ?? true),
248 'subject' => sanitize_text_field($data['subject'] ?? ''),
249 'message' => self::sanitizeEditorContent($data['message'] ?? ''),
250 'smartLogic' => (bool)($data['smartLogic'] ?? false),
251 'formId' => (int)($data['formId'] ?? 0),
252 ];
253 }
254 /**
255 * Sanitize all confirmation data
256 *
257 * @param mixed[] $data
258 *
259 * @return mixed[]
260 */
261 public static function sanitizeConfirmationData(array $data): array
262 {
263 return [
264 'id' => (int)($data['id'] ?? 0),
265 'formId' => (int)($data['formId'] ?? 0),
266 'type' => sanitize_text_field($data['type'] ?? ''),
267 'enabled' => (bool)($data['enabled'] ?? true),
268 'showForm' => (bool)($data['showForm'] ?? false),
269 'message' => self::sanitizeEditorContent($data['message'] ?? ''),
270 'url' => sanitize_url(
271 isset($data['url']) ? urldecode($data['url']) : ''
272 ),
273 'page' => sanitize_text_field($data['page'] ?? ''),
274 ];
275 }
276
277 /**
278 * Sanitize form fields
279 *
280 * @param mixed[] $fields
281 *
282 * @return mixed[]
283 * @throws InvalidArgumentException
284 */
285 private static function sanitizeFields(array $fields): array
286 {
287 return array_map(function ($field) {
288 // Validate field type
289 $fieldType = sanitize_text_field($field['type'] ?? '');
290 if (!FieldType::isValid($field['type'])) {
291 throw new InvalidArgumentException(
292 BackendStrings::getExceptionStrings()['invalid_field_type']
293 );
294 }
295
296 $sanitizedField = [
297 'id' => (int)($field['id'] ?? 0),
298 'fieldIndex' => (int)($field['fieldIndex'] ?? 0),
299 'type' => $fieldType,
300 'label' => sanitize_text_field($field['label'] ?? ''),
301 'required' => (bool)($field['required'] ?? false),
302 'defaultValue' => sanitize_text_field($field['defaultValue'] ?? ''),
303 'placeholder' => sanitize_text_field($field['placeholder'] ?? ''),
304 'position' => (int)($field['position'] ?? 0),
305 'rowIndex' => max(0, (int)($field['rowIndex'] ?? 0)),
306 'columnIndex' => max(0, min(4, (int)($field['columnIndex'] ?? 0))),
307 'width' => max(20, min(100, (int)($field['width'] ?? 100))),
308 'parentId' => isset($field['parentId']) ? max(0, (int)$field['parentId']) : null,
309 'hideLabel' => (bool)($field['hideLabel'] ?? false),
310 'readOnly' => (bool)($field['readOnly'] ?? false),
311 'description' => sanitize_text_field($field['description'] ?? ''),
312 'requiredMessage' => sanitize_text_field($field['requiredMessage'] ?? ''),
313 'cssClasses' => sanitize_text_field($field['cssClasses'] ?? ''),
314 'fieldOptions' => self::sanitizeFieldOptions($field['fieldOptions'] ?? []),
315 'shuffleOptions' => (bool)($field['shuffleOptions'] ?? false),
316 'showValues' => (bool)($field['showValues'] ?? false),
317 'enableSearch' => (bool)($field['enableSearch'] ?? false),
318 'rows' => (int)($field['rows'] ?? 0),
319 'confirmFieldEnabled' => (bool)($field['confirmFieldEnabled'] ?? false),
320 'confirmFieldLabel' => sanitize_text_field($field['confirmFieldLabel'] ?? ''),
321 'confirmFieldPlaceholder' => sanitize_text_field($field['confirmFieldPlaceholder'] ?? ''),
322 'confirmFieldHideLabel' => (bool)($field['confirmFieldHideLabel'] ?? false),
323 'phoneFormat' => self::sanitizePhoneFormat($field['phoneFormat'] ?? ''),
324 'phoneAutoDetect' => (bool)($field['phoneAutoDetect'] ?? false),
325 'minValue' => self::sanitizeNumericValue($field['minValue'] ?? null),
326 'maxValue' => self::sanitizeNumericValue($field['maxValue'] ?? null),
327 'step' => (float)($field['step'] ?? 1.0),
328 'numberFormat' => sanitize_text_field($field['numberFormat'] ?? ''),
329 'visible' => (bool)($field['visible'] ?? true),
330 'limitMaxLength' => (bool)($field['limitMaxLength'] ?? false),
331 'maxLength' => (int)($field['maxLength'] ?? 255),
332 'customValidationMessage' => sanitize_text_field($field['customValidationMessage'] ?? ''),
333 'labelPosition' => sanitize_text_field($field['labelPosition'] ?? 'default'),
334 'noDuplicates' => (bool)($field['noDuplicates'] ?? false),
335 'inputPrefix' => sanitize_text_field($field['inputPrefix'] ?? ''),
336 'inputSuffix' => sanitize_text_field($field['inputSuffix'] ?? ''),
337 'timeFieldType' => sanitize_text_field($field['timeFieldType'] ?? ''),
338 'timeFormat' => sanitize_text_field($field['timeFormat'] ?? ''),
339 'dateFieldType' => sanitize_text_field($field['dateFieldType'] ?? ''),
340 'dateFormat' => sanitize_text_field($field['dateFormat'] ?? ''),
341 'minDateValue' => sanitize_text_field($field['minDateValue'] ?? ''),
342 'maxDateValue' => sanitize_text_field($field['maxDateValue'] ?? ''),
343 'showRatingText' => (bool)($field['showRatingText'] ?? false),
344 'ratingIcon' => sanitize_text_field($field['ratingIcon'] ?? 'star'),
345 ];
346
347 /**
348 * This filter allows to sanitize form fields properties
349 * for fields that are not part of the core plugin.
350 *
351 * @since 0.1.0
352 *
353 * @param mixed[] $sanitizedField The sanitized field data so far.
354 * @param mixed[] $field The original field data before sanitization.
355 * @return mixed[] The modified sanitized field data.
356 */
357 return apply_filters('ivyforms/sanitizer/field_properties', $sanitizedField, $field);
358 }, $fields);
359 }
360
361 /**
362 * Sanitize field options
363 *
364 * @param mixed[] $options
365 *
366 * @return mixed[]
367 */
368 private static function sanitizeFieldOptions(array $options): array
369 {
370 return array_map(function ($option) {
371 return [
372 'id' => (int)($option['id'] ?? 0),
373 'fieldId' => (int)($option['fieldId'] ?? 0),
374 'label' => sanitize_text_field($option['label'] ?? ''),
375 'value' => sanitize_text_field($option['value'] ?? ''),
376 'isDefault' => (bool)($option['isDefault'] ?? false),
377 'position' => (int)($option['position'] ?? 1),
378 ];
379 }, $options);
380 }
381
382 private static function sanitizePhoneFormat(string $format): string
383 {
384 $allowed = ['international','national','e164'];
385 $formatLower = strtolower($format);
386 return in_array($formatLower, $allowed, true) ? $formatLower : '';
387 }
388
389 /**
390 * Sanitize entry data
391 *
392 * @param mixed[] $data
393 *
394 * @return mixed[]
395 */
396 public static function sanitizeEntryData(array $data): array
397 {
398 return [
399 'id' => (int)($data['id'] ?? 0),
400 'formId' => (int)($data['formId'] ?? 0),
401 'userId' => (int)($data['userId'] ?? 0),
402 'status' => sanitize_text_field($data['status'] ?? ''),
403 'ipAddress' => sanitize_text_field($data['ipAddress'] ?? ''),
404 'userAgent' => sanitize_text_field($data['userAgent'] ?? ''),
405 'sourceURL' => sanitize_text_field($data['sourceURL'] ?? ''),
406 'dateCreated' => sanitize_text_field($data['dateCreated'] ?? ''),
407 'dateEdited' => sanitize_text_field($data['dateEdited'] ?? ''),
408 'starred' => (bool)($data['starred'] ?? false),
409 ];
410 }
411
412 /**
413 * Sanitize ID
414 *
415 * @param int $id
416 *
417 * @return int
418 *
419 * @throws InvalidArgumentException
420 */
421 public static function sanitizeId(int $id): int
422 {
423 if ($id <= 0) {
424 throw new InvalidArgumentException(
425 BackendStrings::getExceptionStrings()['invalid_id']
426 );
427 }
428
429 return $id;
430 }
431
432 /**
433 * Sanitize multiple IDs
434 *
435 * @param int[] $ids
436 *
437 * @return int[]
438 *
439 */
440 public static function sanitizeIds(array $ids): array
441 {
442 return array_map(function ($id) {
443 return (int)$id;
444 }, $ids);
445 }
446
447 /**
448 * Sanitize search parameters
449 *
450 * @param array<string, mixed> $params
451 *
452 * @return array<string, mixed>
453 * @throws InvalidArgumentException
454 */
455 public static function sanitizeSearchParams(array $params): array
456 {
457 $filters = [
458 'starred' => array_key_exists('starred', $params['filters'] ?? [])
459 ? (int)filter_var($params['filters']['starred'], FILTER_VALIDATE_BOOLEAN)
460 : null,
461 'published' => array_key_exists('published', $params['filters'] ?? [])
462 ? (int)filter_var($params['filters']['published'], FILTER_VALIDATE_BOOLEAN)
463 : null,
464 'status' => sanitize_text_field($params['filters']['read'] ?? ''),
465 ];
466 // Add formId to filters if present and valid
467 if (
468 isset($params['filters']['formId']) &&
469 is_numeric($params['filters']['formId']) &&
470 (int)$params['filters']['formId'] > 0
471 ) {
472 $filters['formId'] = self::sanitizeId((int)$params['filters']['formId']);
473 }
474 return [
475 'page' => max((int)($params['page'] ?? 1), 1),
476 'perPage' => max((int)($params['perPage'] ?? 10), 1),
477 'search' => sanitize_text_field($params['search'] ?? ''),
478 'orderBy' => sanitize_text_field($params['orderBy'] ?? 'id'),
479 'order' => strtolower(sanitize_text_field($params['order'] ?? 'asc')) === 'desc' ? 'desc' : 'asc',
480 'filters' => $filters,
481 'dateRange' => [
482 sanitize_text_field($params['dateRange'][0] ?? ''),
483 sanitize_text_field($params['dateRange'][1] ?? ''),
484 ],
485 'shouldGetCount' => (bool)($params['shouldGetCount'] ?? false),
486 'searchFieldValue' => filter_var($params['searchFieldValue'] ?? false, FILTER_VALIDATE_BOOLEAN),
487 ];
488 }
489
490 /**
491 * Sanitize form submission data based on field types and IDs
492 *
493 * @param mixed[] $data Form submission data
494 * @param mixed[] $formFields Form fields configuration
495 *
496 * @return mixed[] Sanitized submission data
497 */
498 public static function sanitizeFormSubmissionData(array $data, array $formFields): array
499 {
500 $sanitizedData = [];
501 list($fieldIdMap, $typeIndexMap) = self::buildFieldMaps($formFields);
502
503 foreach ($data['values'] as $key => $value) {
504 if (self::isInternalField($key)) {
505 $sanitizedData[$key] = $value;
506 continue;
507 }
508 if (isset($fieldIdMap[$key])) {
509 $sanitizedData[$key] = self::sanitizeByFieldType($fieldIdMap[$key], $value);
510 continue;
511 }
512 if (isset($typeIndexMap[$key])) {
513 $fieldId = $typeIndexMap[$key]['id'];
514 $fieldType = $typeIndexMap[$key]['type'];
515 $sanitizedData[$fieldId] = self::sanitizeByFieldType($fieldType, $value);
516 }
517 }
518
519 // Add form ID to sanitized data if present
520 if (isset($data['formId'])) {
521 $sanitizedData['formId'] = (int)$data['formId'];
522 }
523
524 // Add post ID and referer if present (for placeholders)
525 if (isset($data['postId'])) {
526 $sanitizedData['postId'] = (int)($data['postId']);
527 }
528 if (isset($data['referer'])) {
529 $sanitizedData['referer'] = sanitize_text_field($data['referer']);
530 }
531
532 return $sanitizedData;
533 }
534
535 /**
536 * Build field ID and type-index maps from form fields
537 *
538 * @param mixed[] $formFields Form fields configuration
539 * @return mixed[]
540 */
541 private static function buildFieldMaps(array $formFields): array
542 {
543 $fieldIdMap = [];
544 $typeIndexMap = [];
545 foreach ($formFields as $field) {
546 $fieldId = $field->getId() ?? null;
547 $fieldType = $field->getType() ?? null;
548 $fieldIndex = $field->getIndex() ?? null;
549 if ($fieldId !== null) {
550 $fieldIdMap[$fieldId] = $fieldType;
551 if ($fieldType !== null && $fieldIndex !== null) {
552 $typeIndexMap[$fieldType . '_' . $fieldIndex] = [
553 'id' => $fieldId,
554 'type' => $fieldType
555 ];
556 }
557 }
558 }
559 return [$fieldIdMap, $typeIndexMap];
560 }
561
562 /**
563 * Check if a key is an internal field
564 *
565 * @param string $key
566 * @return bool
567 */
568 private static function isInternalField(string $key): bool
569 {
570 return in_array($key, ['nonce', 'formId']);
571 }
572
573 /**
574 * Sanitize a value based on its field type
575 *
576 * @param string $fieldType The type of field
577 * @param mixed $value The value to sanitize
578 *
579 * @return mixed Sanitized value
580 */
581 private static function sanitizeByFieldType(string $fieldType, $value)
582 {
583 // Recursively sanitize arrays/objects (parent-child fields)
584 if (is_array($value)) {
585 return array_map(function ($v) use ($fieldType) {
586 return self::sanitizeByFieldType($fieldType, $v);
587 }, $value);
588 }
589 if (is_object($value)) {
590 $sanitized = array_map(function ($v) use ($fieldType) {
591 return self::sanitizeByFieldType($fieldType, $v);
592 }, get_object_vars($value));
593 return (object)$sanitized;
594 }
595 switch ($fieldType) {
596 case 'email':
597 return sanitize_email($value);
598
599 case 'number':
600 // Preserve decimals
601 return is_numeric($value) ? (float)$value : 0.0;
602
603 case 'phone':
604 // Remove everything except digits, +, and spaces
605 return preg_replace('/[^\d\s+]/', '', $value);
606
607 case 'paragraph':
608 return sanitize_textarea_field($value);
609
610 case 'website':
611 return sanitize_url($value);
612
613 case 'time':
614 $sanitizedTime = preg_replace('/[^0-9: ]/i', '', (string)$value);
615 return sanitize_text_field($sanitizedTime);
616
617 case 'date':
618 return sanitize_text_field($value);
619
620 case 'text':
621 default:
622 // Default to text field sanitization
623 return self::sanitizeText($value);
624 }
625 }
626
627 /**
628 * Verify nonce with custom error handling
629 *
630 * @param string|null $nonce
631 *
632 * @throws ForbiddenException
633 */
634 public static function verifyNonce(?string $nonce): void
635 {
636 $nonce = sanitize_text_field($nonce);
637
638 if (!$nonce || !wp_verify_nonce($nonce, 'wp_rest')) {
639 throw new ForbiddenException(
640 BackendStrings::getExceptionStrings()['invalid_nonce']
641 );
642 }
643 }
644 /**
645 * Verify submission nonce with custom error handling
646 *
647 * @param string|null $nonce
648 * @param int $formId
649 *
650 * @throws ForbiddenException
651 */
652 public static function verifySubmissionNonce(?string $nonce, int $formId): void
653 {
654 $nonce = sanitize_text_field($nonce);
655
656 // Verify the form-specific nonce
657 if (!wp_verify_nonce($nonce, 'ivyformsFrontSubmissionNonce_' . $formId)) {
658 throw new ForbiddenException(
659 BackendStrings::getExceptionStrings()['invalid_nonce']
660 );
661 }
662 }
663
664 /**
665 * Sanitize text
666 *
667 * @param string|null $text
668 *
669 * @return string
670 */
671 public static function sanitizeText(?string $text): ?string
672 {
673 if (is_null($text)) {
674 return '';
675 }
676
677 return wp_kses_post(sanitize_text_field($text));
678 }
679
680 /**
681 * Sanitize settings fields
682 *
683 * @param mixed $value
684 *
685 * @return mixed Sanitized value
686 */
687 public static function sanitizeSettingsFields($value)
688 {
689 if (is_array($value)) {
690 return array_map([self::class, 'sanitizeSettingsFields'], $value);
691 }
692
693 if (is_object($value)) {
694 return (object)array_map([self::class, 'sanitizeSettingsFields'], (array)$value);
695 }
696
697 if (is_string($value)) {
698 return self::sanitizeText($value);
699 }
700
701 if (is_int($value)) {
702 return intval($value);
703 }
704
705 if (is_bool($value)) {
706 return filter_var($value, FILTER_VALIDATE_BOOLEAN);
707 }
708
709 return '';
710 }
711
712 /**
713 * Sanitize email or placeholder
714 *
715 * @param string $value
716 *
717 * @return string
718 */
719 public static function sanitizeEmailOrPlaceholder($value): string
720 {
721 $value = trim($value);
722 // Allow placeholders
723 if (preg_match('/^{{[^}]+}}$/', $value)) {
724 return $value;
725 }
726 // Otherwise, sanitize as email
727 return sanitize_email($value);
728 }
729
730 /**
731 * Sanitize numeric values for min/max fields
732 * Convert empty/unselected to null, but allow 0 as a valid value
733 *
734 * @param mixed $value
735 * @return float|null
736 */
737 private static function sanitizeNumericValue($value): ?float
738 {
739 // If value is null or empty string, return null (not selected)
740 if ($value === null || $value === '') {
741 return null;
742 }
743
744 // If value is numeric (including 0), convert to float
745 if (is_numeric($value)) {
746 return (float)$value;
747 }
748
749 // If not numeric, return null
750 return null;
751 }
752
753 /**
754 * Validate email address.
755 *
756 * @param string $email
757 * @return bool
758 */
759 public static function isValidEmail(string $email): bool
760 {
761 return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
762 }
763
764 /**
765 * Normalize field value to string (comma-separated if array)
766 * @param mixed $value
767 * @return string
768 */
769 public static function normalizeFieldValue($value): string
770 {
771 if (is_array($value)) {
772 return implode(', ', $value);
773 }
774 return (string)$value;
775 }
776
777 /**
778 * Sanitize integration settings
779 * Recursively handles nested structures and various data types.
780 *
781 * @param mixed $integrationSettings
782 *
783 * @return array<string, array<string, mixed>>
784 */
785 public static function sanitizeIntegrationSettings($integrationSettings): array
786 {
787 // Handle null or non-array inputs
788 if (!is_array($integrationSettings)) {
789 return [];
790 }
791
792 $cleanedSettings = [];
793
794 foreach ($integrationSettings as $integration => $settings) {
795 // Skip if not a valid integration name or settings structure
796 if (!is_string($integration) || !is_array($settings)) {
797 continue;
798 }
799
800 $cleanedSettings[sanitize_text_field($integration)] = self::sanitizeIntegrationSettingsRecursive($settings);
801 }
802
803 return $cleanedSettings;
804 }
805
806 /**
807 * Recursively sanitize integration settings values
808 *
809 * @param mixed $value
810 * @return mixed
811 */
812 public static function sanitizeIntegrationSettingsRecursive($value)
813 {
814 // Handle arrays recursively
815 if (is_array($value)) {
816 $sanitized = [];
817 foreach ($value as $key => $val) {
818 $sanitizedKey = is_string($key) ? sanitize_text_field($key) : $key;
819 $sanitized[$sanitizedKey] = self::sanitizeIntegrationSettingsRecursive($val);
820 }
821 return $sanitized;
822 }
823
824 // Handle different scalar types
825 if (is_bool($value)) {
826 return $value;
827 }
828 if (is_int($value)) {
829 return $value;
830 }
831 if (is_float($value)) {
832 return $value;
833 }
834 if (is_string($value)) {
835 return sanitize_text_field($value);
836 }
837
838 return '';
839 }
840
841 /**
842 * Get allowed HTML tags and attributes for wp_kses sanitization.
843 * Includes support for signature images with data: URIs.
844 *
845 * @return array<string, array<string, bool>>
846 */
847 public static function getAllowedHtmlTags(): array
848 {
849 return [
850 'img' => [
851 'src' => true,
852 'alt' => true,
853 'style' => true,
854 'class' => true,
855 'width' => true,
856 'height' => true,
857 ],
858 'a' => [
859 'href' => true,
860 'title' => true,
861 'target' => true,
862 'rel' => true,
863 ],
864 'p' => ['class' => true, 'style' => true],
865 'br' => [],
866 'strong' => [],
867 'b' => [],
868 'em' => [],
869 'i' => [],
870 'u' => [],
871 'span' => ['class' => true, 'style' => true],
872 'div' => ['class' => true, 'style' => true],
873 'ul' => ['class' => true],
874 'ol' => ['class' => true],
875 'li' => ['class' => true],
876 'h1' => ['class' => true, 'style' => true],
877 'h2' => ['class' => true, 'style' => true],
878 'h3' => ['class' => true, 'style' => true],
879 'h4' => ['class' => true, 'style' => true],
880 'h5' => ['class' => true, 'style' => true],
881 'h6' => ['class' => true, 'style' => true],
882 ];
883 }
884
885 /**
886 * Get allowed protocols for wp_kses sanitization.
887 * Includes 'data' protocol for signature images.
888 *
889 * @return array<int, string>
890 */
891 public static function getAllowedProtocols(): array
892 {
893 return ['http', 'https', 'mailto', 'data'];
894 }
895
896 /**
897 * Sanitize HTML content using wp_kses with support for signature images.
898 *
899 * @param string $content
900 * @return string
901 */
902 public static function sanitizeHtmlContent(string $content): string
903 {
904 return wp_kses($content, self::getAllowedHtmlTags(), self::getAllowedProtocols());
905 }
906 }
907