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

905 lines 31.2 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 ];
344
345 /**
346 * This filter allows to sanitize form fields properties
347 * for fields that are not part of the core plugin.
348 *
349 * @since 0.1.0
350 *
351 * @param mixed[] $sanitizedField The sanitized field data so far.
352 * @param mixed[] $field The original field data before sanitization.
353 * @return mixed[] The modified sanitized field data.
354 */
355 return apply_filters('ivyforms/sanitizer/field_properties', $sanitizedField, $field);
356 }, $fields);
357 }
358
359 /**
360 * Sanitize field options
361 *
362 * @param mixed[] $options
363 *
364 * @return mixed[]
365 */
366 private static function sanitizeFieldOptions(array $options): array
367 {
368 return array_map(function ($option) {
369 return [
370 'id' => (int)($option['id'] ?? 0),
371 'fieldId' => (int)($option['fieldId'] ?? 0),
372 'label' => sanitize_text_field($option['label'] ?? ''),
373 'value' => sanitize_text_field($option['value'] ?? ''),
374 'isDefault' => (bool)($option['isDefault'] ?? false),
375 'position' => (int)($option['position'] ?? 1),
376 ];
377 }, $options);
378 }
379
380 private static function sanitizePhoneFormat(string $format): string
381 {
382 $allowed = ['international','national','e164'];
383 $formatLower = strtolower($format);
384 return in_array($formatLower, $allowed, true) ? $formatLower : '';
385 }
386
387 /**
388 * Sanitize entry data
389 *
390 * @param mixed[] $data
391 *
392 * @return mixed[]
393 */
394 public static function sanitizeEntryData(array $data): array
395 {
396 return [
397 'id' => (int)($data['id'] ?? 0),
398 'formId' => (int)($data['formId'] ?? 0),
399 'userId' => (int)($data['userId'] ?? 0),
400 'status' => sanitize_text_field($data['status'] ?? ''),
401 'ipAddress' => sanitize_text_field($data['ipAddress'] ?? ''),
402 'userAgent' => sanitize_text_field($data['userAgent'] ?? ''),
403 'sourceURL' => sanitize_text_field($data['sourceURL'] ?? ''),
404 'dateCreated' => sanitize_text_field($data['dateCreated'] ?? ''),
405 'dateEdited' => sanitize_text_field($data['dateEdited'] ?? ''),
406 'starred' => (bool)($data['starred'] ?? false),
407 ];
408 }
409
410 /**
411 * Sanitize ID
412 *
413 * @param int $id
414 *
415 * @return int
416 *
417 * @throws InvalidArgumentException
418 */
419 public static function sanitizeId(int $id): int
420 {
421 if ($id <= 0) {
422 throw new InvalidArgumentException(
423 BackendStrings::getExceptionStrings()['invalid_id']
424 );
425 }
426
427 return $id;
428 }
429
430 /**
431 * Sanitize multiple IDs
432 *
433 * @param int[] $ids
434 *
435 * @return int[]
436 *
437 */
438 public static function sanitizeIds(array $ids): array
439 {
440 return array_map(function ($id) {
441 return (int)$id;
442 }, $ids);
443 }
444
445 /**
446 * Sanitize search parameters
447 *
448 * @param array<string, mixed> $params
449 *
450 * @return array<string, mixed>
451 * @throws InvalidArgumentException
452 */
453 public static function sanitizeSearchParams(array $params): array
454 {
455 $filters = [
456 'starred' => array_key_exists('starred', $params['filters'] ?? [])
457 ? (int)filter_var($params['filters']['starred'], FILTER_VALIDATE_BOOLEAN)
458 : null,
459 'published' => array_key_exists('published', $params['filters'] ?? [])
460 ? (int)filter_var($params['filters']['published'], FILTER_VALIDATE_BOOLEAN)
461 : null,
462 'status' => sanitize_text_field($params['filters']['read'] ?? ''),
463 ];
464 // Add formId to filters if present and valid
465 if (
466 isset($params['filters']['formId']) &&
467 is_numeric($params['filters']['formId']) &&
468 (int)$params['filters']['formId'] > 0
469 ) {
470 $filters['formId'] = self::sanitizeId((int)$params['filters']['formId']);
471 }
472 return [
473 'page' => max((int)($params['page'] ?? 1), 1),
474 'perPage' => max((int)($params['perPage'] ?? 10), 1),
475 'search' => sanitize_text_field($params['search'] ?? ''),
476 'orderBy' => sanitize_text_field($params['orderBy'] ?? 'id'),
477 'order' => strtolower(sanitize_text_field($params['order'] ?? 'asc')) === 'desc' ? 'desc' : 'asc',
478 'filters' => $filters,
479 'dateRange' => [
480 sanitize_text_field($params['dateRange'][0] ?? ''),
481 sanitize_text_field($params['dateRange'][1] ?? ''),
482 ],
483 'shouldGetCount' => (bool)($params['shouldGetCount'] ?? false),
484 'searchFieldValue' => filter_var($params['searchFieldValue'] ?? false, FILTER_VALIDATE_BOOLEAN),
485 ];
486 }
487
488 /**
489 * Sanitize form submission data based on field types and IDs
490 *
491 * @param mixed[] $data Form submission data
492 * @param mixed[] $formFields Form fields configuration
493 *
494 * @return mixed[] Sanitized submission data
495 */
496 public static function sanitizeFormSubmissionData(array $data, array $formFields): array
497 {
498 $sanitizedData = [];
499 list($fieldIdMap, $typeIndexMap) = self::buildFieldMaps($formFields);
500
501 foreach ($data['values'] as $key => $value) {
502 if (self::isInternalField($key)) {
503 $sanitizedData[$key] = $value;
504 continue;
505 }
506 if (isset($fieldIdMap[$key])) {
507 $sanitizedData[$key] = self::sanitizeByFieldType($fieldIdMap[$key], $value);
508 continue;
509 }
510 if (isset($typeIndexMap[$key])) {
511 $fieldId = $typeIndexMap[$key]['id'];
512 $fieldType = $typeIndexMap[$key]['type'];
513 $sanitizedData[$fieldId] = self::sanitizeByFieldType($fieldType, $value);
514 }
515 }
516
517 // Add form ID to sanitized data if present
518 if (isset($data['formId'])) {
519 $sanitizedData['formId'] = (int)$data['formId'];
520 }
521
522 // Add post ID and referer if present (for placeholders)
523 if (isset($data['postId'])) {
524 $sanitizedData['postId'] = (int)($data['postId']);
525 }
526 if (isset($data['referer'])) {
527 $sanitizedData['referer'] = sanitize_text_field($data['referer']);
528 }
529
530 return $sanitizedData;
531 }
532
533 /**
534 * Build field ID and type-index maps from form fields
535 *
536 * @param mixed[] $formFields Form fields configuration
537 * @return mixed[]
538 */
539 private static function buildFieldMaps(array $formFields): array
540 {
541 $fieldIdMap = [];
542 $typeIndexMap = [];
543 foreach ($formFields as $field) {
544 $fieldId = $field->getId() ?? null;
545 $fieldType = $field->getType() ?? null;
546 $fieldIndex = $field->getIndex() ?? null;
547 if ($fieldId !== null) {
548 $fieldIdMap[$fieldId] = $fieldType;
549 if ($fieldType !== null && $fieldIndex !== null) {
550 $typeIndexMap[$fieldType . '_' . $fieldIndex] = [
551 'id' => $fieldId,
552 'type' => $fieldType
553 ];
554 }
555 }
556 }
557 return [$fieldIdMap, $typeIndexMap];
558 }
559
560 /**
561 * Check if a key is an internal field
562 *
563 * @param string $key
564 * @return bool
565 */
566 private static function isInternalField(string $key): bool
567 {
568 return in_array($key, ['nonce', 'formId']);
569 }
570
571 /**
572 * Sanitize a value based on its field type
573 *
574 * @param string $fieldType The type of field
575 * @param mixed $value The value to sanitize
576 *
577 * @return mixed Sanitized value
578 */
579 private static function sanitizeByFieldType(string $fieldType, $value)
580 {
581 // Recursively sanitize arrays/objects (parent-child fields)
582 if (is_array($value)) {
583 return array_map(function ($v) use ($fieldType) {
584 return self::sanitizeByFieldType($fieldType, $v);
585 }, $value);
586 }
587 if (is_object($value)) {
588 $sanitized = array_map(function ($v) use ($fieldType) {
589 return self::sanitizeByFieldType($fieldType, $v);
590 }, get_object_vars($value));
591 return (object)$sanitized;
592 }
593 switch ($fieldType) {
594 case 'email':
595 return sanitize_email($value);
596
597 case 'number':
598 // Preserve decimals
599 return is_numeric($value) ? (float)$value : 0.0;
600
601 case 'phone':
602 // Remove everything except digits, +, and spaces
603 return preg_replace('/[^\d\s+]/', '', $value);
604
605 case 'paragraph':
606 return sanitize_textarea_field($value);
607
608 case 'website':
609 return sanitize_url($value);
610
611 case 'time':
612 $sanitizedTime = preg_replace('/[^0-9: ]/i', '', (string)$value);
613 return sanitize_text_field($sanitizedTime);
614
615 case 'date':
616 return sanitize_text_field($value);
617
618 case 'text':
619 default:
620 // Default to text field sanitization
621 return self::sanitizeText($value);
622 }
623 }
624
625 /**
626 * Verify nonce with custom error handling
627 *
628 * @param string|null $nonce
629 *
630 * @throws ForbiddenException
631 */
632 public static function verifyNonce(?string $nonce): void
633 {
634 $nonce = sanitize_text_field($nonce);
635
636 if (!$nonce || !wp_verify_nonce($nonce, 'wp_rest')) {
637 throw new ForbiddenException(
638 BackendStrings::getExceptionStrings()['invalid_nonce']
639 );
640 }
641 }
642 /**
643 * Verify submission nonce with custom error handling
644 *
645 * @param string|null $nonce
646 * @param int $formId
647 *
648 * @throws ForbiddenException
649 */
650 public static function verifySubmissionNonce(?string $nonce, int $formId): void
651 {
652 $nonce = sanitize_text_field($nonce);
653
654 // Verify the form-specific nonce
655 if (!wp_verify_nonce($nonce, 'ivyformsFrontSubmissionNonce_' . $formId)) {
656 throw new ForbiddenException(
657 BackendStrings::getExceptionStrings()['invalid_nonce']
658 );
659 }
660 }
661
662 /**
663 * Sanitize text
664 *
665 * @param string|null $text
666 *
667 * @return string
668 */
669 public static function sanitizeText(?string $text): ?string
670 {
671 if (is_null($text)) {
672 return '';
673 }
674
675 return wp_kses_post(sanitize_text_field($text));
676 }
677
678 /**
679 * Sanitize settings fields
680 *
681 * @param mixed $value
682 *
683 * @return mixed Sanitized value
684 */
685 public static function sanitizeSettingsFields($value)
686 {
687 if (is_array($value)) {
688 return array_map([self::class, 'sanitizeSettingsFields'], $value);
689 }
690
691 if (is_object($value)) {
692 return (object)array_map([self::class, 'sanitizeSettingsFields'], (array)$value);
693 }
694
695 if (is_string($value)) {
696 return self::sanitizeText($value);
697 }
698
699 if (is_int($value)) {
700 return intval($value);
701 }
702
703 if (is_bool($value)) {
704 return filter_var($value, FILTER_VALIDATE_BOOLEAN);
705 }
706
707 return '';
708 }
709
710 /**
711 * Sanitize email or placeholder
712 *
713 * @param string $value
714 *
715 * @return string
716 */
717 public static function sanitizeEmailOrPlaceholder($value): string
718 {
719 $value = trim($value);
720 // Allow placeholders
721 if (preg_match('/^{{[^}]+}}$/', $value)) {
722 return $value;
723 }
724 // Otherwise, sanitize as email
725 return sanitize_email($value);
726 }
727
728 /**
729 * Sanitize numeric values for min/max fields
730 * Convert empty/unselected to null, but allow 0 as a valid value
731 *
732 * @param mixed $value
733 * @return float|null
734 */
735 private static function sanitizeNumericValue($value): ?float
736 {
737 // If value is null or empty string, return null (not selected)
738 if ($value === null || $value === '') {
739 return null;
740 }
741
742 // If value is numeric (including 0), convert to float
743 if (is_numeric($value)) {
744 return (float)$value;
745 }
746
747 // If not numeric, return null
748 return null;
749 }
750
751 /**
752 * Validate email address.
753 *
754 * @param string $email
755 * @return bool
756 */
757 public static function isValidEmail(string $email): bool
758 {
759 return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
760 }
761
762 /**
763 * Normalize field value to string (comma-separated if array)
764 * @param mixed $value
765 * @return string
766 */
767 public static function normalizeFieldValue($value): string
768 {
769 if (is_array($value)) {
770 return implode(', ', $value);
771 }
772 return (string)$value;
773 }
774
775 /**
776 * Sanitize integration settings
777 * Recursively handles nested structures and various data types.
778 *
779 * @param mixed $integrationSettings
780 *
781 * @return array<string, array<string, mixed>>
782 */
783 public static function sanitizeIntegrationSettings($integrationSettings): array
784 {
785 // Handle null or non-array inputs
786 if (!is_array($integrationSettings)) {
787 return [];
788 }
789
790 $cleanedSettings = [];
791
792 foreach ($integrationSettings as $integration => $settings) {
793 // Skip if not a valid integration name or settings structure
794 if (!is_string($integration) || !is_array($settings)) {
795 continue;
796 }
797
798 $cleanedSettings[sanitize_text_field($integration)] = self::sanitizeIntegrationSettingsRecursive($settings);
799 }
800
801 return $cleanedSettings;
802 }
803
804 /**
805 * Recursively sanitize integration settings values
806 *
807 * @param mixed $value
808 * @return mixed
809 */
810 public static function sanitizeIntegrationSettingsRecursive($value)
811 {
812 // Handle arrays recursively
813 if (is_array($value)) {
814 $sanitized = [];
815 foreach ($value as $key => $val) {
816 $sanitizedKey = is_string($key) ? sanitize_text_field($key) : $key;
817 $sanitized[$sanitizedKey] = self::sanitizeIntegrationSettingsRecursive($val);
818 }
819 return $sanitized;
820 }
821
822 // Handle different scalar types
823 if (is_bool($value)) {
824 return $value;
825 }
826 if (is_int($value)) {
827 return $value;
828 }
829 if (is_float($value)) {
830 return $value;
831 }
832 if (is_string($value)) {
833 return sanitize_text_field($value);
834 }
835
836 return '';
837 }
838
839 /**
840 * Get allowed HTML tags and attributes for wp_kses sanitization.
841 * Includes support for signature images with data: URIs.
842 *
843 * @return array<string, array<string, bool>>
844 */
845 public static function getAllowedHtmlTags(): array
846 {
847 return [
848 'img' => [
849 'src' => true,
850 'alt' => true,
851 'style' => true,
852 'class' => true,
853 'width' => true,
854 'height' => true,
855 ],
856 'a' => [
857 'href' => true,
858 'title' => true,
859 'target' => true,
860 'rel' => true,
861 ],
862 'p' => ['class' => true, 'style' => true],
863 'br' => [],
864 'strong' => [],
865 'b' => [],
866 'em' => [],
867 'i' => [],
868 'u' => [],
869 'span' => ['class' => true, 'style' => true],
870 'div' => ['class' => true, 'style' => true],
871 'ul' => ['class' => true],
872 'ol' => ['class' => true],
873 'li' => ['class' => true],
874 'h1' => ['class' => true, 'style' => true],
875 'h2' => ['class' => true, 'style' => true],
876 'h3' => ['class' => true, 'style' => true],
877 'h4' => ['class' => true, 'style' => true],
878 'h5' => ['class' => true, 'style' => true],
879 'h6' => ['class' => true, 'style' => true],
880 ];
881 }
882
883 /**
884 * Get allowed protocols for wp_kses sanitization.
885 * Includes 'data' protocol for signature images.
886 *
887 * @return array<int, string>
888 */
889 public static function getAllowedProtocols(): array
890 {
891 return ['http', 'https', 'mailto', 'data'];
892 }
893
894 /**
895 * Sanitize HTML content using wp_kses with support for signature images.
896 *
897 * @param string $content
898 * @return string
899 */
900 public static function sanitizeHtmlContent(string $content): string
901 {
902 return wp_kses($content, self::getAllowedHtmlTags(), self::getAllowedProtocols());
903 }
904 }
905