PluginProbe
The Innovative Form Builder – IvyForms / 0.6
The Innovative Form Builder – IvyForms v0.6
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.6, at backend/src/Common/Sanitizer/Sanitizer.php

870 lines 29.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * @copyright © TMS-Plugins. 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 all form data
178 *
179 * @param mixed[] $data
180 *
181 * @return mixed[]
182 * @throws InvalidArgumentException
183 */
184 public static function sanitizeFormData(array $data): array
185 {
186 $fields = $data['fields'] ?? [];
187 return [
188 'id' => (int)($data['id'] ?? 0),
189 'name' => sanitize_text_field($data['name'] ?? ''),
190 'description' => sanitize_text_field($data['description'] ?? ''),
191 'showTitle' => (bool)($data['showTitle'] ?? false),
192 'published' => (bool)($data['published'] ?? false),
193 'showDescription' => (bool)($data['showDescription'] ?? false),
194 'storeEntries' => (bool)($data['storeEntries'] ?? false),
195 'fields' => self::sanitizeFields($fields),
196 'integrationSettings' => self::sanitizeIntegrationSettings(
197 $data['integrationSettings'] ?? []
198 )
199 ];
200 }
201 /**
202 * Sanitize all notification data
203 *
204 * @param mixed[] $data
205 *
206 * @return mixed[]
207 */
208 public static function sanitizeNotificationData(array $data): array
209 {
210 return [
211 'id' => (int)($data['id'] ?? 0),
212 'name' => sanitize_text_field($data['name'] ?? ''),
213 'sender' => self::sanitizeEmailOrPlaceholder($data['sender'] ?? ''),
214 'receiver' => self::sanitizeEmailOrPlaceholder($data['receiver'] ?? ''),
215 'enabled' => (bool)($data['enabled'] ?? true),
216 'subject' => sanitize_text_field($data['subject'] ?? ''),
217 'message' => self::sanitizeEditorContent($data['message'] ?? ''),
218 'smartLogic' => (bool)($data['smartLogic'] ?? false),
219 'formId' => (int)($data['formId'] ?? 0),
220 ];
221 }
222 /**
223 * Sanitize all confirmation data
224 *
225 * @param mixed[] $data
226 *
227 * @return mixed[]
228 */
229 public static function sanitizeConfirmationData(array $data): array
230 {
231 return [
232 'id' => (int)($data['id'] ?? 0),
233 'formId' => (int)($data['formId'] ?? 0),
234 'type' => sanitize_text_field($data['type'] ?? ''),
235 'enabled' => (bool)($data['enabled'] ?? true),
236 'showForm' => (bool)($data['showForm'] ?? false),
237 'message' => self::sanitizeEditorContent($data['message'] ?? ''),
238 'url' => sanitize_url(
239 isset($data['url']) ? urldecode($data['url']) : ''
240 ),
241 'page' => sanitize_text_field($data['page'] ?? ''),
242 ];
243 }
244
245 /**
246 * Sanitize form fields
247 *
248 * @param mixed[] $fields
249 *
250 * @return mixed[]
251 * @throws InvalidArgumentException
252 */
253 private static function sanitizeFields(array $fields): array
254 {
255 return array_map(function ($field) {
256 // Validate field type
257 $fieldType = sanitize_text_field($field['type'] ?? '');
258 if (!FieldType::isValid($field['type'])) {
259 throw new InvalidArgumentException(
260 BackendStrings::getExceptionStrings()['invalid_field_type']
261 );
262 }
263
264 $sanitizedField = [
265 'id' => (int)($field['id'] ?? 0),
266 'fieldIndex' => (int)($field['fieldIndex'] ?? 0),
267 'type' => $fieldType,
268 'label' => sanitize_text_field($field['label'] ?? ''),
269 'required' => (bool)($field['required'] ?? false),
270 'defaultValue' => sanitize_text_field($field['defaultValue'] ?? ''),
271 'placeholder' => sanitize_text_field($field['placeholder'] ?? ''),
272 'position' => (int)($field['position'] ?? 0),
273 'parentId' => isset($field['parentId']) ? max(0, (int)$field['parentId']) : null,
274 'hideLabel' => (bool)($field['hideLabel'] ?? false),
275 'readOnly' => (bool)($field['readOnly'] ?? false),
276 'description' => sanitize_text_field($field['description'] ?? ''),
277 'requiredMessage' => sanitize_text_field($field['requiredMessage'] ?? ''),
278 'cssClasses' => sanitize_text_field($field['cssClasses'] ?? ''),
279 'fieldOptions' => self::sanitizeFieldOptions($field['fieldOptions'] ?? []),
280 'shuffleOptions' => (bool)($field['shuffleOptions'] ?? false),
281 'showValues' => (bool)($field['showValues'] ?? false),
282 'enableSearch' => (bool)($field['enableSearch'] ?? false),
283 'rows' => (int)($field['rows'] ?? 0),
284 'confirmFieldEnabled' => (bool)($field['confirmFieldEnabled'] ?? false),
285 'confirmFieldLabel' => sanitize_text_field($field['confirmFieldLabel'] ?? ''),
286 'confirmFieldPlaceholder' => sanitize_text_field($field['confirmFieldPlaceholder'] ?? ''),
287 'confirmFieldHideLabel' => (bool)($field['confirmFieldHideLabel'] ?? false),
288 'phoneFormat' => self::sanitizePhoneFormat($field['phoneFormat'] ?? ''),
289 'phoneAutoDetect' => (bool)($field['phoneAutoDetect'] ?? false),
290 'minValue' => self::sanitizeNumericValue($field['minValue'] ?? null),
291 'maxValue' => self::sanitizeNumericValue($field['maxValue'] ?? null),
292 'step' => (float)($field['step'] ?? 1.0),
293 'numberFormat' => sanitize_text_field($field['numberFormat'] ?? ''),
294 'visible' => (bool)($field['visible'] ?? true),
295 'limitMaxLength' => (bool)($field['limitMaxLength'] ?? false),
296 'maxLength' => (int)($field['maxLength'] ?? 255),
297 'customValidationMessage' => sanitize_text_field($field['customValidationMessage'] ?? ''),
298 'labelPosition' => sanitize_text_field($field['labelPosition'] ?? 'default'),
299 'noDuplicates' => (bool)($field['noDuplicates'] ?? false),
300 'inputPrefix' => sanitize_text_field($field['inputPrefix'] ?? ''),
301 'inputSuffix' => sanitize_text_field($field['inputSuffix'] ?? ''),
302 'timeFieldType' => sanitize_text_field($field['timeFieldType'] ?? ''),
303 'timeFormat' => sanitize_text_field($field['timeFormat'] ?? ''),
304 'dateFieldType' => sanitize_text_field($field['dateFieldType'] ?? ''),
305 'dateFormat' => sanitize_text_field($field['dateFormat'] ?? ''),
306 'minDateValue' => sanitize_text_field($field['minDateValue'] ?? ''),
307 'maxDateValue' => sanitize_text_field($field['maxDateValue'] ?? ''),
308 ];
309
310 /**
311 * This filter allows to sanitize form fields properties
312 * for fields that are not part of the core plugin.
313 *
314 * @since 0.1.0
315 *
316 * @param mixed[] $sanitizedField The sanitized field data so far.
317 * @param mixed[] $field The original field data before sanitization.
318 * @return mixed[] The modified sanitized field data.
319 */
320 return apply_filters('ivyforms/sanitizer/field_properties', $sanitizedField, $field);
321 }, $fields);
322 }
323
324 /**
325 * Sanitize field options
326 *
327 * @param mixed[] $options
328 *
329 * @return mixed[]
330 */
331 private static function sanitizeFieldOptions(array $options): array
332 {
333 return array_map(function ($option) {
334 return [
335 'id' => (int)($option['id'] ?? 0),
336 'fieldId' => (int)($option['fieldId'] ?? 0),
337 'label' => sanitize_text_field($option['label'] ?? ''),
338 'value' => sanitize_text_field($option['value'] ?? ''),
339 'isDefault' => (bool)($option['isDefault'] ?? false),
340 'position' => (int)($option['position'] ?? 1),
341 ];
342 }, $options);
343 }
344
345 private static function sanitizePhoneFormat(string $format): string
346 {
347 $allowed = ['international','national','e164'];
348 $formatLower = strtolower($format);
349 return in_array($formatLower, $allowed, true) ? $formatLower : '';
350 }
351
352 /**
353 * Sanitize entry data
354 *
355 * @param mixed[] $data
356 *
357 * @return mixed[]
358 */
359 public static function sanitizeEntryData(array $data): array
360 {
361 return [
362 'id' => (int)($data['id'] ?? 0),
363 'formId' => (int)($data['formId'] ?? 0),
364 'userId' => (int)($data['userId'] ?? 0),
365 'status' => sanitize_text_field($data['status'] ?? ''),
366 'ipAddress' => sanitize_text_field($data['ipAddress'] ?? ''),
367 'userAgent' => sanitize_text_field($data['userAgent'] ?? ''),
368 'sourceURL' => sanitize_text_field($data['sourceURL'] ?? ''),
369 'dateCreated' => sanitize_text_field($data['dateCreated'] ?? ''),
370 'dateEdited' => sanitize_text_field($data['dateEdited'] ?? ''),
371 'starred' => (bool)($data['starred'] ?? false),
372 ];
373 }
374
375 /**
376 * Sanitize ID
377 *
378 * @param int $id
379 *
380 * @return int
381 *
382 * @throws InvalidArgumentException
383 */
384 public static function sanitizeId(int $id): int
385 {
386 if ($id <= 0) {
387 throw new InvalidArgumentException(
388 BackendStrings::getExceptionStrings()['invalid_id']
389 );
390 }
391
392 return $id;
393 }
394
395 /**
396 * Sanitize multiple IDs
397 *
398 * @param int[] $ids
399 *
400 * @return int[]
401 *
402 */
403 public static function sanitizeIds(array $ids): array
404 {
405 return array_map(function ($id) {
406 return (int)$id;
407 }, $ids);
408 }
409
410 /**
411 * Sanitize search parameters
412 *
413 * @param array<string, mixed> $params
414 *
415 * @return array<string, mixed>
416 * @throws InvalidArgumentException
417 */
418 public static function sanitizeSearchParams(array $params): array
419 {
420 $filters = [
421 'starred' => array_key_exists('starred', $params['filters'] ?? [])
422 ? (int)filter_var($params['filters']['starred'], FILTER_VALIDATE_BOOLEAN)
423 : null,
424 'published' => array_key_exists('published', $params['filters'] ?? [])
425 ? (int)filter_var($params['filters']['published'], FILTER_VALIDATE_BOOLEAN)
426 : null,
427 'status' => sanitize_text_field($params['filters']['read'] ?? ''),
428 ];
429 // Add formId to filters if present and valid
430 if (
431 isset($params['filters']['formId']) &&
432 is_numeric($params['filters']['formId']) &&
433 (int)$params['filters']['formId'] > 0
434 ) {
435 $filters['formId'] = self::sanitizeId((int)$params['filters']['formId']);
436 }
437 return [
438 'page' => max((int)($params['page'] ?? 1), 1),
439 'perPage' => max((int)($params['perPage'] ?? 10), 1),
440 'search' => sanitize_text_field($params['search'] ?? ''),
441 'orderBy' => sanitize_text_field($params['orderBy'] ?? 'id'),
442 'order' => strtolower(sanitize_text_field($params['order'] ?? 'asc')) === 'desc' ? 'desc' : 'asc',
443 'filters' => $filters,
444 'dateRange' => [
445 sanitize_text_field($params['dateRange'][0] ?? ''),
446 sanitize_text_field($params['dateRange'][1] ?? ''),
447 ],
448 'shouldGetCount' => (bool)($params['shouldGetCount'] ?? false),
449 'searchFieldValue' => filter_var($params['searchFieldValue'] ?? false, FILTER_VALIDATE_BOOLEAN),
450 ];
451 }
452
453 /**
454 * Sanitize form submission data based on field types and IDs
455 *
456 * @param mixed[] $data Form submission data
457 * @param mixed[] $formFields Form fields configuration
458 *
459 * @return mixed[] Sanitized submission data
460 */
461 public static function sanitizeFormSubmissionData(array $data, array $formFields): array
462 {
463 $sanitizedData = [];
464 list($fieldIdMap, $typeIndexMap) = self::buildFieldMaps($formFields);
465
466 foreach ($data['values'] as $key => $value) {
467 if (self::isInternalField($key)) {
468 $sanitizedData[$key] = $value;
469 continue;
470 }
471 if (isset($fieldIdMap[$key])) {
472 $sanitizedData[$key] = self::sanitizeByFieldType($fieldIdMap[$key], $value);
473 continue;
474 }
475 if (isset($typeIndexMap[$key])) {
476 $fieldId = $typeIndexMap[$key]['id'];
477 $fieldType = $typeIndexMap[$key]['type'];
478 $sanitizedData[$fieldId] = self::sanitizeByFieldType($fieldType, $value);
479 }
480 }
481
482 // Add form ID to sanitized data if present
483 if (isset($data['formId'])) {
484 $sanitizedData['formId'] = (int)$data['formId'];
485 }
486
487 // Add post ID and referer if present (for placeholders)
488 if (isset($data['postId'])) {
489 $sanitizedData['postId'] = (int)($data['postId']);
490 }
491 if (isset($data['referer'])) {
492 $sanitizedData['referer'] = sanitize_text_field($data['referer']);
493 }
494
495 return $sanitizedData;
496 }
497
498 /**
499 * Build field ID and type-index maps from form fields
500 *
501 * @param mixed[] $formFields Form fields configuration
502 * @return mixed[]
503 */
504 private static function buildFieldMaps(array $formFields): array
505 {
506 $fieldIdMap = [];
507 $typeIndexMap = [];
508 foreach ($formFields as $field) {
509 $fieldId = $field->getId() ?? null;
510 $fieldType = $field->getType() ?? null;
511 $fieldIndex = $field->getIndex() ?? null;
512 if ($fieldId !== null) {
513 $fieldIdMap[$fieldId] = $fieldType;
514 if ($fieldType !== null && $fieldIndex !== null) {
515 $typeIndexMap[$fieldType . '_' . $fieldIndex] = [
516 'id' => $fieldId,
517 'type' => $fieldType
518 ];
519 }
520 }
521 }
522 return [$fieldIdMap, $typeIndexMap];
523 }
524
525 /**
526 * Check if a key is an internal field
527 *
528 * @param string $key
529 * @return bool
530 */
531 private static function isInternalField(string $key): bool
532 {
533 return in_array($key, ['nonce', 'formId']);
534 }
535
536 /**
537 * Sanitize a value based on its field type
538 *
539 * @param string $fieldType The type of field
540 * @param mixed $value The value to sanitize
541 *
542 * @return mixed Sanitized value
543 */
544 private static function sanitizeByFieldType(string $fieldType, $value)
545 {
546 // Recursively sanitize arrays/objects (parent-child fields)
547 if (is_array($value)) {
548 return array_map(function ($v) use ($fieldType) {
549 return self::sanitizeByFieldType($fieldType, $v);
550 }, $value);
551 }
552 if (is_object($value)) {
553 $sanitized = array_map(function ($v) use ($fieldType) {
554 return self::sanitizeByFieldType($fieldType, $v);
555 }, get_object_vars($value));
556 return (object)$sanitized;
557 }
558 switch ($fieldType) {
559 case 'email':
560 return sanitize_email($value);
561
562 case 'number':
563 // Preserve decimals
564 return is_numeric($value) ? (float)$value : 0.0;
565
566 case 'phone':
567 // Remove everything except digits, +, and spaces
568 return preg_replace('/[^\d\s+]/', '', $value);
569
570 case 'paragraph':
571 return sanitize_textarea_field($value);
572
573 case 'website':
574 return sanitize_url($value);
575
576 case 'time':
577 $sanitizedTime = preg_replace('/[^0-9: ]/i', '', (string)$value);
578 return sanitize_text_field($sanitizedTime);
579
580 case 'date':
581 return sanitize_text_field($value);
582
583 case 'text':
584 default:
585 // Default to text field sanitization
586 return self::sanitizeText($value);
587 }
588 }
589
590 /**
591 * Verify nonce with custom error handling
592 *
593 * @param string|null $nonce
594 *
595 * @throws ForbiddenException
596 */
597 public static function verifyNonce(?string $nonce): void
598 {
599 $nonce = sanitize_text_field($nonce);
600
601 if (!$nonce || !wp_verify_nonce($nonce, 'wp_rest')) {
602 throw new ForbiddenException(
603 BackendStrings::getExceptionStrings()['invalid_nonce']
604 );
605 }
606 }
607 /**
608 * Verify submission nonce with custom error handling
609 *
610 * @param string|null $nonce
611 * @param int $formId
612 *
613 * @throws ForbiddenException
614 */
615 public static function verifySubmissionNonce(?string $nonce, int $formId): void
616 {
617 $nonce = sanitize_text_field($nonce);
618
619 // Verify the form-specific nonce
620 if (!wp_verify_nonce($nonce, 'ivyformsFrontSubmissionNonce_' . $formId)) {
621 throw new ForbiddenException(
622 BackendStrings::getExceptionStrings()['invalid_nonce']
623 );
624 }
625 }
626
627 /**
628 * Sanitize text
629 *
630 * @param string|null $text
631 *
632 * @return string
633 */
634 public static function sanitizeText(?string $text): ?string
635 {
636 if (is_null($text)) {
637 return '';
638 }
639
640 return wp_kses_post(sanitize_text_field($text));
641 }
642
643 /**
644 * Sanitize settings fields
645 *
646 * @param mixed $value
647 *
648 * @return mixed Sanitized value
649 */
650 public static function sanitizeSettingsFields($value)
651 {
652 if (is_array($value)) {
653 return array_map([self::class, 'sanitizeSettingsFields'], $value);
654 }
655
656 if (is_object($value)) {
657 return (object)array_map([self::class, 'sanitizeSettingsFields'], (array)$value);
658 }
659
660 if (is_string($value)) {
661 return self::sanitizeText($value);
662 }
663
664 if (is_int($value)) {
665 return intval($value);
666 }
667
668 if (is_bool($value)) {
669 return filter_var($value, FILTER_VALIDATE_BOOLEAN);
670 }
671
672 return '';
673 }
674
675 /**
676 * Sanitize email or placeholder
677 *
678 * @param string $value
679 *
680 * @return string
681 */
682 public static function sanitizeEmailOrPlaceholder($value): string
683 {
684 $value = trim($value);
685 // Allow placeholders
686 if (preg_match('/^{{[^}]+}}$/', $value)) {
687 return $value;
688 }
689 // Otherwise, sanitize as email
690 return sanitize_email($value);
691 }
692
693 /**
694 * Sanitize numeric values for min/max fields
695 * Convert empty/unselected to null, but allow 0 as a valid value
696 *
697 * @param mixed $value
698 * @return float|null
699 */
700 private static function sanitizeNumericValue($value): ?float
701 {
702 // If value is null or empty string, return null (not selected)
703 if ($value === null || $value === '') {
704 return null;
705 }
706
707 // If value is numeric (including 0), convert to float
708 if (is_numeric($value)) {
709 return (float)$value;
710 }
711
712 // If not numeric, return null
713 return null;
714 }
715
716 /**
717 * Validate email address.
718 *
719 * @param string $email
720 * @return bool
721 */
722 public static function isValidEmail(string $email): bool
723 {
724 return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
725 }
726
727 /**
728 * Normalize field value to string (comma-separated if array)
729 * @param mixed $value
730 * @return string
731 */
732 public static function normalizeFieldValue($value): string
733 {
734 if (is_array($value)) {
735 return implode(', ', $value);
736 }
737 return (string)$value;
738 }
739
740 /**
741 * Sanitize integration settings
742 * Recursively handles nested structures and various data types.
743 *
744 * @param mixed $integrationSettings
745 *
746 * @return array<string, array<string, mixed>>
747 */
748 public static function sanitizeIntegrationSettings($integrationSettings): array
749 {
750 // Handle null or non-array inputs
751 if (!is_array($integrationSettings)) {
752 return [];
753 }
754
755 $cleanedSettings = [];
756
757 foreach ($integrationSettings as $integration => $settings) {
758 // Skip if not a valid integration name or settings structure
759 if (!is_string($integration) || !is_array($settings)) {
760 continue;
761 }
762
763 $cleanedSettings[sanitize_text_field($integration)] = self::sanitizeIntegrationSettingsRecursive($settings);
764 }
765
766 return $cleanedSettings;
767 }
768
769 /**
770 * Recursively sanitize integration settings values
771 *
772 * @param mixed $value
773 * @return mixed
774 */
775 public static function sanitizeIntegrationSettingsRecursive($value)
776 {
777 // Handle arrays recursively
778 if (is_array($value)) {
779 $sanitized = [];
780 foreach ($value as $key => $val) {
781 $sanitizedKey = is_string($key) ? sanitize_text_field($key) : $key;
782 $sanitized[$sanitizedKey] = self::sanitizeIntegrationSettingsRecursive($val);
783 }
784 return $sanitized;
785 }
786
787 // Handle different scalar types
788 if (is_bool($value)) {
789 return $value;
790 }
791 if (is_int($value)) {
792 return $value;
793 }
794 if (is_float($value)) {
795 return $value;
796 }
797 if (is_string($value)) {
798 return sanitize_text_field($value);
799 }
800
801 return '';
802 }
803
804 /**
805 * Get allowed HTML tags and attributes for wp_kses sanitization.
806 * Includes support for signature images with data: URIs.
807 *
808 * @return array<string, array<string, bool>>
809 */
810 public static function getAllowedHtmlTags(): array
811 {
812 return [
813 'img' => [
814 'src' => true,
815 'alt' => true,
816 'style' => true,
817 'class' => true,
818 'width' => true,
819 'height' => true,
820 ],
821 'a' => [
822 'href' => true,
823 'title' => true,
824 'target' => true,
825 'rel' => true,
826 ],
827 'p' => ['class' => true, 'style' => true],
828 'br' => [],
829 'strong' => [],
830 'b' => [],
831 'em' => [],
832 'i' => [],
833 'u' => [],
834 'span' => ['class' => true, 'style' => true],
835 'div' => ['class' => true, 'style' => true],
836 'ul' => ['class' => true],
837 'ol' => ['class' => true],
838 'li' => ['class' => true],
839 'h1' => ['class' => true, 'style' => true],
840 'h2' => ['class' => true, 'style' => true],
841 'h3' => ['class' => true, 'style' => true],
842 'h4' => ['class' => true, 'style' => true],
843 'h5' => ['class' => true, 'style' => true],
844 'h6' => ['class' => true, 'style' => true],
845 ];
846 }
847
848 /**
849 * Get allowed protocols for wp_kses sanitization.
850 * Includes 'data' protocol for signature images.
851 *
852 * @return array<int, string>
853 */
854 public static function getAllowedProtocols(): array
855 {
856 return ['http', 'https', 'mailto', 'data'];
857 }
858
859 /**
860 * Sanitize HTML content using wp_kses with support for signature images.
861 *
862 * @param string $content
863 * @return string
864 */
865 public static function sanitizeHtmlContent(string $content): string
866 {
867 return wp_kses($content, self::getAllowedHtmlTags(), self::getAllowedProtocols());
868 }
869 }
870