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

1,067 lines 37.4 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 // Extract data: URLs in img src attributes (preserve them from wp_kses stripping)
93 $data_url_placeholders = [];
94 $data_url_index = 0;
95
96 // Extract data: image URLs and replace with safe placeholders in data-img-src attribute
97 $content = preg_replace_callback(
98 '/src\s*=\s*["\']?(data:image\/[^"\'>\s]+)["\']?/i',
99 function ($matches) use (&$data_url_placeholders, &$data_url_index) {
100 $placeholder = 'IMGDATA' . $data_url_index;
101 $data_url_placeholders[$placeholder] = $matches[1];
102 $data_url_index++;
103 // Use data-img-src instead of src so wp_kses doesn't strip it
104 return 'data-img-src="' . $placeholder . '"';
105 },
106 $content
107 );
108
109 $content = preg_replace_callback(
110 '/(<[^>]+)\s+style="([^"]*)"/i',
111 function ($matches) use (&$style_placeholders, &$placeholder_index) {
112 $placeholder = '___STYLE_PLACEHOLDER_' . $placeholder_index . '___';
113 // Sanitize the CSS value for security
114 $style_placeholders[$placeholder] = self::sanitizeCssValue(
115 $matches[2]
116 );
117 $placeholder_index++;
118 $html = ' data-style-placeholder="' . $placeholder . '"';
119 return $matches[1] . $html;
120 },
121 $content
122 );
123
124 // Define allowed HTML tags and attributes for the editor
125 $allowed_tags = wp_kses_allowed_html('post');
126
127 // Add necessary attributes to tags
128 $tags_with_style = [
129 'span',
130 'p',
131 'div',
132 'h1',
133 'h2',
134 'h3',
135 'h4',
136 'h5',
137 'h6',
138 'li',
139 'ul',
140 'ol',
141 'blockquote',
142 'strong',
143 'em',
144 'u',
145 'a'
146 ];
147
148 foreach ($tags_with_style as $tag) {
149 if (!isset($allowed_tags[$tag])) {
150 $allowed_tags[$tag] = [];
151 }
152 if (!is_array($allowed_tags[$tag])) {
153 $allowed_tags[$tag] = [];
154 }
155 $allowed_tags[$tag]['data-style-placeholder'] = true;
156 $allowed_tags[$tag]['class'] = true;
157 }
158
159 // Ensure 'a' tag has all necessary attributes
160 if (!isset($allowed_tags['a'])) {
161 $allowed_tags['a'] = [];
162 }
163 $allowed_tags['a']['href'] = true;
164 $allowed_tags['a']['target'] = true;
165 $allowed_tags['a']['rel'] = true;
166 $allowed_tags['a']['class'] = true;
167 $allowed_tags['a']['data-style-placeholder'] = true;
168
169 // Ensure 'img' tag is in allowed tags with data: URLs support
170 if (!isset($allowed_tags['img'])) {
171 $allowed_tags['img'] = [];
172 }
173 $allowed_tags['img']['src'] = true;
174 $allowed_tags['img']['alt'] = true;
175 $allowed_tags['img']['class'] = true;
176 $allowed_tags['img']['style'] = true;
177 $allowed_tags['img']['width'] = true;
178 $allowed_tags['img']['height'] = true;
179 $allowed_tags['img']['data-img-src'] = true; // Allow placeholder attribute
180
181 // Sanitize HTML structure (but style content is preserved in placeholders)
182 $result = wp_kses($content, $allowed_tags);
183
184 // Restore data: URLs from data-img-src back to src attribute
185 $result = preg_replace_callback(
186 '/<img[^>]*data-img-src=["\']?(IMGDATA\d+)["\']?[^>]*>/i',
187 function ($matches) use ($data_url_placeholders) {
188 // Get the full img tag
189 $imgTag = $matches[0];
190 // Find the placeholder
191 $placeholderMatch = [];
192 if (preg_match('/data-img-src=["\']?(IMGDATA\d+)["\']?/i', $imgTag, $placeholderMatch)) {
193 $placeholder = $placeholderMatch[1];
194 if (isset($data_url_placeholders[$placeholder])) {
195 // Remove data-img-src
196 $imgTag = preg_replace('/\s*data-img-src=["\']?IMGDATA\d+["\']?/i', '', $imgTag);
197 // Only add src if it does not already exist
198 if (!preg_match('/\ssrc=["\']?[^"\'>]+["\']?/i', $imgTag)) {
199 // Add src attribute before the closing >
200 $imgTag = str_replace('>', ' src="' . $data_url_placeholders[$placeholder] . '">', $imgTag);
201 }
202 return $imgTag;
203 }
204 }
205 return $matches[0];
206 },
207 $result
208 );
209
210
211 // Restore style attributes from placeholders
212 $result = preg_replace_callback(
213 '/data-style-placeholder="(___STYLE_PLACEHOLDER_\d+___)"/i',
214 function ($matches) use ($style_placeholders) {
215 $placeholder = $matches[1];
216 if (isset($style_placeholders[$placeholder])) {
217 $sanitized_style = $style_placeholders[$placeholder];
218 // Only add style attribute if there's actual content
219 if (!empty($sanitized_style)) {
220 return 'style="' . $sanitized_style . '"';
221 }
222 }
223 return '';
224 },
225 $result
226 );
227
228
229 return $result;
230 }
231
232 /**
233 * Sanitize form action buttons settings
234 *
235 * @param mixed[] $settings
236 *
237 * @return mixed[]
238 */
239 public static function sanitizeFormActionButtons(array $settings): array
240 {
241 $submitButtonSettings = $settings['submitButtonSettings'] ?? [];
242 $validPositions = ['default', 'left', 'center', 'right'];
243 $position = $submitButtonSettings['position'] ?? 'default';
244
245 // Validate position
246 if (!in_array($position, $validPositions, true)) {
247 $position = 'default';
248 }
249
250 return [
251 'submitButtonSettings' => [
252 'label' => sanitize_text_field(
253 $submitButtonSettings['label'] ?? BackendStrings::getCommonStrings()['submit']
254 ),
255 'position' => $position
256 ]
257 ];
258 }
259
260 /**
261 * Sanitize all form data
262 *
263 * @param mixed[] $data
264 *
265 * @return mixed[]
266 * @throws InvalidArgumentException
267 */
268 public static function sanitizeFormData(array $data): array
269 {
270 $fields = $data['fields'] ?? [];
271 return [
272 'id' => (int)($data['id'] ?? 0),
273 'name' => sanitize_text_field($data['name'] ?? ''),
274 'description' => sanitize_text_field($data['description'] ?? ''),
275 'showTitle' => (bool)($data['showTitle'] ?? false),
276 'published' => (bool)($data['published'] ?? false),
277 'showDescription' => (bool)($data['showDescription'] ?? false),
278 'storeEntries' => (bool)($data['storeEntries'] ?? false),
279 'fields' => self::sanitizeFields($fields),
280 'integrationSettings' => self::sanitizeIntegrationSettings(
281 $data['integrationSettings'] ?? []
282 ),
283 'formActionButtons' => self::sanitizeFormActionButtons(
284 $data['formActionButtons'] ?? []
285 )
286 ];
287 }
288 /**
289 * Sanitize all notification data
290 *
291 * @param mixed[] $data
292 *
293 * @return mixed[]
294 */
295 public static function sanitizeNotificationData(array $data): array
296 {
297 return [
298 'id' => (int)($data['id'] ?? 0),
299 'name' => sanitize_text_field($data['name'] ?? ''),
300 'sender' => self::sanitizeEmailOrPlaceholder($data['sender'] ?? ''),
301 'replyTo' => self::sanitizeEmailOrPlaceholder($data['replyTo'] ?? ''),
302 'receiver' => self::sanitizeEmailOrPlaceholder($data['receiver'] ?? ''),
303 'enabled' => (bool)($data['enabled'] ?? true),
304 'subject' => sanitize_text_field($data['subject'] ?? ''),
305 'message' => self::sanitizeEditorContent($data['message'] ?? ''),
306 'smartLogic' => (bool)($data['smartLogic'] ?? false),
307 'formId' => (int)($data['formId'] ?? 0),
308 ];
309 }
310 /**
311 * Sanitize all confirmation data
312 *
313 * @param mixed[] $data
314 *
315 * @return mixed[]
316 */
317 public static function sanitizeConfirmationData(array $data): array
318 {
319 return [
320 'id' => (int)($data['id'] ?? 0),
321 'formId' => (int)($data['formId'] ?? 0),
322 'type' => sanitize_text_field($data['type'] ?? ''),
323 'enabled' => (bool)($data['enabled'] ?? true),
324 'showForm' => (bool)($data['showForm'] ?? false),
325 'message' => self::sanitizeEditorContent($data['message'] ?? ''),
326 'url' => sanitize_url(
327 isset($data['url']) ? urldecode($data['url']) : ''
328 ),
329 'page' => sanitize_text_field($data['page'] ?? ''),
330 ];
331 }
332
333 /**
334 * Sanitize form fields
335 *
336 * @param mixed[] $fields
337 *
338 * @return mixed[]
339 * @throws InvalidArgumentException
340 */
341 private static function sanitizeFields(array $fields): array
342 {
343 return array_map(function ($field) {
344 // Validate field type
345 $fieldType = sanitize_text_field($field['type'] ?? '');
346 if (!FieldType::isValid($field['type'])) {
347 throw new InvalidArgumentException(
348 BackendStrings::getExceptionStrings()['invalid_field_type']
349 );
350 }
351
352 $sanitizedField = [
353 'id' => (int)($field['id'] ?? 0),
354 'fieldIndex' => (int)($field['fieldIndex'] ?? 0),
355 'type' => $fieldType,
356 'label' => sanitize_text_field($field['label'] ?? ''),
357 'required' => (bool)($field['required'] ?? false),
358 'defaultValue' => sanitize_text_field($field['defaultValue'] ?? ''),
359 'placeholder' => sanitize_text_field($field['placeholder'] ?? ''),
360 'position' => (int)($field['position'] ?? 0),
361 'rowIndex' => max(0, (int)($field['rowIndex'] ?? 0)),
362 'columnIndex' => max(0, min(4, (int)($field['columnIndex'] ?? 0))),
363 'width' => max(20, min(100, (int)($field['width'] ?? 100))),
364 'parentId' => isset($field['parentId']) ? max(0, (int)$field['parentId']) : null,
365 'hideLabel' => (bool)($field['hideLabel'] ?? false),
366 'readOnly' => (bool)($field['readOnly'] ?? false),
367 'description' => sanitize_text_field($field['description'] ?? ''),
368 'requiredMessage' => sanitize_text_field($field['requiredMessage'] ?? ''),
369 'cssClasses' => sanitize_text_field($field['cssClasses'] ?? ''),
370 'fieldOptions' => self::sanitizeFieldOptions($field['fieldOptions'] ?? []),
371 'shuffleOptions' => (bool)($field['shuffleOptions'] ?? false),
372 'showValues' => (bool)($field['showValues'] ?? false),
373 'enableSearch' => (bool)($field['enableSearch'] ?? false),
374 'rows' => (int)($field['rows'] ?? 0),
375 'confirmFieldEnabled' => (bool)($field['confirmFieldEnabled'] ?? false),
376 'confirmFieldLabel' => sanitize_text_field($field['confirmFieldLabel'] ?? ''),
377 'confirmFieldPlaceholder' => sanitize_text_field($field['confirmFieldPlaceholder'] ?? ''),
378 'confirmFieldHideLabel' => (bool)($field['confirmFieldHideLabel'] ?? false),
379 'phoneFormat' => self::sanitizePhoneFormat($field['phoneFormat'] ?? ''),
380 'phoneAutoDetect' => (bool)($field['phoneAutoDetect'] ?? false),
381 'minValue' => self::sanitizeNumericValue($field['minValue'] ?? null),
382 'maxValue' => self::sanitizeNumericValue($field['maxValue'] ?? null),
383 'step' => (float)($field['step'] ?? 1.0),
384 'numberFormat' => sanitize_text_field($field['numberFormat'] ?? ''),
385 'visible' => (bool)($field['visible'] ?? true),
386 'limitMaxLength' => (bool)($field['limitMaxLength'] ?? false),
387 'limitRange' => (bool)($field['limitRange'] ?? false),
388 'maxLength' => (int)($field['maxLength'] ?? 255),
389 'customValidationMessage' => sanitize_text_field($field['customValidationMessage'] ?? ''),
390 'labelPosition' => sanitize_text_field($field['labelPosition'] ?? 'default'),
391 'noDuplicates' => (bool)($field['noDuplicates'] ?? false),
392 'inputPrefix' => sanitize_text_field($field['inputPrefix'] ?? ''),
393 'inputSuffix' => sanitize_text_field($field['inputSuffix'] ?? ''),
394 'timeFieldType' => sanitize_text_field($field['timeFieldType'] ?? ''),
395 'timeFormat' => sanitize_text_field($field['timeFormat'] ?? ''),
396 'dateFieldType' => sanitize_text_field($field['dateFieldType'] ?? ''),
397 'dateFormat' => sanitize_text_field($field['dateFormat'] ?? ''),
398 'minDateValue' => sanitize_text_field($field['minDateValue'] ?? ''),
399 'maxDateValue' => sanitize_text_field($field['maxDateValue'] ?? ''),
400 'showRatingText' => (bool)($field['showRatingText'] ?? false),
401 'ratingIcon' => sanitize_text_field($field['ratingIcon'] ?? 'star'),
402 'htmlContent' => self::sanitizeEditorContent($field['htmlContent'] ?? ''),
403 'limitDateRange' => (bool)($field['limitDateRange'] ?? false),
404 'minTimeValue' => sanitize_text_field($field['minTimeValue'] ?? ''),
405 'maxTimeValue' => sanitize_text_field($field['maxTimeValue'] ?? ''),
406 'limitTimeRange' => (bool)($field['limitTimeRange'] ?? false),
407 ];
408
409 /**
410 * This filter allows to sanitize form fields properties
411 * for fields that are not part of the core plugin.
412 *
413 * @since 0.1.0
414 *
415 * @param mixed[] $sanitizedField The sanitized field data so far.
416 * @param mixed[] $field The original field data before sanitization.
417 * @return mixed[] The modified sanitized field data.
418 */
419 return apply_filters('ivyforms/sanitizer/field_properties', $sanitizedField, $field);
420 }, $fields);
421 }
422
423 /**
424 * Sanitize field options
425 *
426 * @param mixed[] $options
427 *
428 * @return mixed[]
429 */
430 private static function sanitizeFieldOptions(array $options): array
431 {
432 return array_map(function ($option) {
433 return [
434 'id' => (int)($option['id'] ?? 0),
435 'fieldId' => (int)($option['fieldId'] ?? 0),
436 'label' => sanitize_text_field($option['label'] ?? ''),
437 'value' => sanitize_text_field($option['value'] ?? ''),
438 'isDefault' => (bool)($option['isDefault'] ?? false),
439 'position' => (int)($option['position'] ?? 1),
440 ];
441 }, $options);
442 }
443
444 private static function sanitizePhoneFormat(string $format): string
445 {
446 $allowed = ['international','national','e164'];
447 $formatLower = strtolower($format);
448 return in_array($formatLower, $allowed, true) ? $formatLower : '';
449 }
450
451 /**
452 * Sanitize entry data
453 *
454 * @param mixed[] $data
455 *
456 * @return mixed[]
457 */
458 public static function sanitizeEntryData(array $data): array
459 {
460 return [
461 'id' => (int)($data['id'] ?? 0),
462 'formId' => (int)($data['formId'] ?? 0),
463 'userId' => (int)($data['userId'] ?? 0),
464 'status' => sanitize_text_field($data['status'] ?? ''),
465 'ipAddress' => sanitize_text_field($data['ipAddress'] ?? ''),
466 'userAgent' => sanitize_text_field($data['userAgent'] ?? ''),
467 'sourceURL' => sanitize_text_field($data['sourceURL'] ?? ''),
468 'dateCreated' => sanitize_text_field($data['dateCreated'] ?? ''),
469 'dateEdited' => sanitize_text_field($data['dateEdited'] ?? ''),
470 'starred' => (bool)($data['starred'] ?? false),
471 ];
472 }
473
474 /**
475 * Sanitize ID
476 *
477 * @param int $id
478 *
479 * @return int
480 *
481 * @throws InvalidArgumentException
482 */
483 public static function sanitizeId(int $id): int
484 {
485 if ($id <= 0) {
486 throw new InvalidArgumentException(
487 BackendStrings::getExceptionStrings()['invalid_id']
488 );
489 }
490
491 return $id;
492 }
493
494 /**
495 * Sanitize multiple IDs
496 *
497 * @param int[] $ids
498 *
499 * @return int[]
500 *
501 */
502 public static function sanitizeIds(array $ids): array
503 {
504 return array_map(function ($id) {
505 return (int)$id;
506 }, $ids);
507 }
508
509 /**
510 * Sanitize search parameters
511 *
512 * @param array<string, mixed> $params
513 *
514 * @return array<string, mixed>
515 * @throws InvalidArgumentException
516 */
517 public static function sanitizeSearchParams(array $params): array
518 {
519 $filters = [
520 'starred' => array_key_exists('starred', $params['filters'] ?? [])
521 ? (int)filter_var($params['filters']['starred'], FILTER_VALIDATE_BOOLEAN)
522 : null,
523 'published' => array_key_exists('published', $params['filters'] ?? [])
524 ? (int)filter_var($params['filters']['published'], FILTER_VALIDATE_BOOLEAN)
525 : null,
526 'status' => sanitize_text_field($params['filters']['read'] ?? ''),
527 ];
528 // Add formId to filters if present and valid
529 if (
530 isset($params['filters']['formId']) &&
531 is_numeric($params['filters']['formId']) &&
532 (int)$params['filters']['formId'] > 0
533 ) {
534 $filters['formId'] = self::sanitizeId((int)$params['filters']['formId']);
535 }
536 return [
537 'page' => max((int)($params['page'] ?? 1), 1),
538 'perPage' => max((int)($params['perPage'] ?? 10), 1),
539 'search' => sanitize_text_field($params['search'] ?? ''),
540 'orderBy' => sanitize_text_field($params['orderBy'] ?? 'id'),
541 'order' => strtolower(sanitize_text_field($params['order'] ?? 'asc')) === 'desc' ? 'desc' : 'asc',
542 'filters' => $filters,
543 'dateRange' => [
544 sanitize_text_field($params['dateRange'][0] ?? ''),
545 sanitize_text_field($params['dateRange'][1] ?? ''),
546 ],
547 'shouldGetCount' => (bool)($params['shouldGetCount'] ?? false),
548 'searchFieldValue' => filter_var($params['searchFieldValue'] ?? false, FILTER_VALIDATE_BOOLEAN),
549 ];
550 }
551
552 /**
553 * Sanitize form submission data based on field types and IDs
554 *
555 * @param mixed[] $data Form submission data
556 * @param mixed[] $formFields Form fields configuration
557 *
558 * @return mixed[] Sanitized submission data
559 */
560 public static function sanitizeFormSubmissionData(array $data, array $formFields): array
561 {
562 $sanitizedData = [];
563 list($fieldIdMap, $typeIndexMap) = self::buildFieldMaps($formFields);
564
565 foreach ($data['values'] as $key => $value) {
566 if (self::isInternalField($key)) {
567 $sanitizedData[$key] = $value;
568 continue;
569 }
570 if (isset($fieldIdMap[$key])) {
571 $sanitizedData[$key] = self::sanitizeByFieldType($fieldIdMap[$key], $value);
572 continue;
573 }
574 if (isset($typeIndexMap[$key])) {
575 $fieldId = $typeIndexMap[$key]['id'];
576 $fieldType = $typeIndexMap[$key]['type'];
577 $sanitizedData[$fieldId] = self::sanitizeByFieldType($fieldType, $value);
578 }
579 }
580
581 // Add form ID to sanitized data if present
582 if (isset($data['formId'])) {
583 $sanitizedData['formId'] = (int)$data['formId'];
584 }
585
586 // Add post ID and referer if present (for placeholders)
587 if (isset($data['postId'])) {
588 $sanitizedData['postId'] = (int)($data['postId']);
589 }
590 if (isset($data['referer'])) {
591 $sanitizedData['referer'] = sanitize_text_field($data['referer']);
592 }
593
594 return $sanitizedData;
595 }
596
597 /**
598 * Build field ID and type-index maps from form fields
599 *
600 * @param mixed[] $formFields Form fields configuration
601 * @return mixed[]
602 */
603 private static function buildFieldMaps(array $formFields): array
604 {
605 $fieldIdMap = [];
606 $typeIndexMap = [];
607 foreach ($formFields as $field) {
608 $fieldId = $field->getId() ?? null;
609 $fieldType = $field->getType() ?? null;
610 $fieldIndex = $field->getIndex() ?? null;
611 if ($fieldId !== null) {
612 $fieldIdMap[$fieldId] = $fieldType;
613 if ($fieldType !== null && $fieldIndex !== null) {
614 $typeIndexMap[$fieldType . '_' . $fieldIndex] = [
615 'id' => $fieldId,
616 'type' => $fieldType
617 ];
618 }
619 }
620 }
621 return [$fieldIdMap, $typeIndexMap];
622 }
623
624 /**
625 * Check if a key is an internal field
626 *
627 * @param string $key
628 * @return bool
629 */
630 private static function isInternalField(string $key): bool
631 {
632 return in_array($key, ['nonce', 'formId']);
633 }
634
635 /**
636 * Sanitize a value based on its field type
637 *
638 * @param string $fieldType The type of field
639 * @param mixed $value The value to sanitize
640 *
641 * @return mixed Sanitized value
642 */
643 private static function sanitizeByFieldType(string $fieldType, $value)
644 {
645 // Recursively sanitize arrays/objects (parent-child fields)
646 if (is_array($value)) {
647 return array_map(function ($v) use ($fieldType) {
648 return self::sanitizeByFieldType($fieldType, $v);
649 }, $value);
650 }
651 if (is_object($value)) {
652 $sanitized = array_map(function ($v) use ($fieldType) {
653 return self::sanitizeByFieldType($fieldType, $v);
654 }, get_object_vars($value));
655 return (object)$sanitized;
656 }
657 switch ($fieldType) {
658 case 'email':
659 return sanitize_email($value);
660
661 case 'number':
662 // Preserve decimals
663 return is_numeric($value) ? (float)$value : 0.0;
664
665 case 'phone':
666 // Remove everything except digits, +, and spaces
667 return preg_replace('/[^\d\s+]/', '', $value);
668
669 case 'paragraph':
670 return sanitize_textarea_field($value);
671
672 case 'website':
673 return sanitize_url($value);
674
675 case 'time':
676 $sanitizedTime = preg_replace('/[^0-9: ]/i', '', (string)$value);
677 return sanitize_text_field($sanitizedTime);
678
679 case 'date':
680 return sanitize_text_field($value);
681
682 case 'text':
683 default:
684 // Default to text field sanitization
685 return self::sanitizeText($value);
686 }
687 }
688
689 /**
690 * Verify nonce with custom error handling
691 *
692 * @param string|null $nonce
693 *
694 * @throws ForbiddenException
695 */
696 public static function verifyNonce(?string $nonce): void
697 {
698 $nonce = sanitize_text_field($nonce);
699
700 if (!$nonce || !wp_verify_nonce($nonce, 'wp_rest')) {
701 throw new ForbiddenException(
702 BackendStrings::getExceptionStrings()['invalid_nonce']
703 );
704 }
705 }
706 /**
707 * Verify submission nonce with custom error handling
708 *
709 * @param string|null $nonce
710 * @param int $formId
711 *
712 * @throws ForbiddenException
713 */
714 public static function verifySubmissionNonce(?string $nonce, int $formId): void
715 {
716 $nonce = sanitize_text_field($nonce);
717
718 // Verify the form-specific nonce
719 if (!wp_verify_nonce($nonce, 'ivyformsFrontSubmissionNonce_' . $formId)) {
720 throw new ForbiddenException(
721 BackendStrings::getExceptionStrings()['invalid_nonce']
722 );
723 }
724 }
725
726 /**
727 * Sanitize text
728 *
729 * @param string|null $text
730 *
731 * @return string
732 */
733 public static function sanitizeText(?string $text): ?string
734 {
735 if (is_null($text)) {
736 return '';
737 }
738
739 return wp_kses_post(sanitize_text_field($text));
740 }
741
742 /**
743 * Sanitize settings fields
744 *
745 * @param mixed $value
746 *
747 * @return mixed Sanitized value
748 */
749 public static function sanitizeSettingsFields($value)
750 {
751 if (is_array($value)) {
752 return array_map([self::class, 'sanitizeSettingsFields'], $value);
753 }
754
755 if (is_object($value)) {
756 return (object)array_map([self::class, 'sanitizeSettingsFields'], (array)$value);
757 }
758
759 if (is_string($value)) {
760 return self::sanitizeText($value);
761 }
762
763 if (is_int($value)) {
764 return intval($value);
765 }
766
767 if (is_bool($value)) {
768 return filter_var($value, FILTER_VALIDATE_BOOLEAN);
769 }
770
771 return '';
772 }
773
774 /**
775 * Sanitize email or placeholder
776 *
777 * @param string $value
778 *
779 * @return string
780 */
781 public static function sanitizeEmailOrPlaceholder($value): string
782 {
783 $value = trim($value);
784 // Allow placeholders
785 if (preg_match('/^{{[^}]+}}$/', $value)) {
786 return $value;
787 }
788 // Otherwise, sanitize as email
789 return sanitize_email($value);
790 }
791
792 /**
793 * Sanitize numeric values for min/max fields
794 * Convert empty/unselected to null, but allow 0 as a valid value
795 *
796 * @param mixed $value
797 * @return float|null
798 */
799 private static function sanitizeNumericValue($value): ?float
800 {
801 // If value is null or empty string, return null (not selected)
802 if ($value === null || $value === '') {
803 return null;
804 }
805
806 // If value is numeric (including 0), convert to float
807 if (is_numeric($value)) {
808 return (float)$value;
809 }
810
811 // If not numeric, return null
812 return null;
813 }
814
815 /**
816 * Validate email address.
817 *
818 * @param string $email
819 * @return bool
820 */
821 public static function isValidEmail(string $email): bool
822 {
823 return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
824 }
825
826 /**
827 * Normalize field value to string (comma-separated if array)
828 * @param mixed $value
829 * @return string
830 */
831 public static function normalizeFieldValue($value): string
832 {
833 if (is_array($value)) {
834 return implode(', ', $value);
835 }
836 return (string)$value;
837 }
838
839 /**
840 * Sanitize integration settings
841 * Recursively handles nested structures and various data types.
842 *
843 * @param mixed $integrationSettings
844 *
845 * @return array<string, array<string, mixed>>
846 */
847 public static function sanitizeIntegrationSettings($integrationSettings): array
848 {
849 // Handle null or non-array inputs
850 if (!is_array($integrationSettings)) {
851 return [];
852 }
853
854 $cleanedSettings = [];
855
856 foreach ($integrationSettings as $integration => $settings) {
857 // Skip if not a valid integration name or settings structure
858 if (!is_string($integration) || !is_array($settings)) {
859 continue;
860 }
861
862 $cleanedSettings[sanitize_text_field($integration)] = self::sanitizeIntegrationSettingsRecursive($settings);
863 }
864
865 return $cleanedSettings;
866 }
867
868 /**
869 * Recursively sanitize integration settings values
870 *
871 * @param mixed $value
872 * @return mixed
873 */
874 public static function sanitizeIntegrationSettingsRecursive($value)
875 {
876 // Handle arrays recursively
877 if (is_array($value)) {
878 $sanitized = [];
879 foreach ($value as $key => $val) {
880 $sanitizedKey = is_string($key) ? sanitize_text_field($key) : $key;
881 $sanitized[$sanitizedKey] = self::sanitizeIntegrationSettingsRecursive($val);
882 }
883 return $sanitized;
884 }
885
886 // Handle different scalar types
887 if (is_bool($value)) {
888 return $value;
889 }
890 if (is_int($value)) {
891 return $value;
892 }
893 if (is_float($value)) {
894 return $value;
895 }
896 if (is_string($value)) {
897 return sanitize_text_field($value);
898 }
899
900 return '';
901 }
902
903 /**
904 * Get allowed HTML tags and attributes for wp_kses sanitization.
905 * Includes support for signature images with data: URIs.
906 *
907 * @return array<string, array<string, bool>>
908 */
909 public static function getAllowedHtmlTags(): array
910 {
911 return [
912 'img' => [
913 'src' => true,
914 'alt' => true,
915 'style' => true,
916 'class' => true,
917 'width' => true,
918 'height' => true,
919 ],
920 'a' => [
921 'href' => true,
922 'title' => true,
923 'target' => true,
924 'rel' => true,
925 ],
926 'p' => ['class' => true, 'style' => true],
927 'br' => [],
928 'strong' => [],
929 'b' => [],
930 'em' => [],
931 'i' => [],
932 'u' => [],
933 'span' => ['class' => true, 'style' => true],
934 'div' => ['class' => true, 'style' => true],
935 'ul' => ['class' => true],
936 'ol' => ['class' => true],
937 'li' => ['class' => true],
938 'h1' => ['class' => true, 'style' => true],
939 'h2' => ['class' => true, 'style' => true],
940 'h3' => ['class' => true, 'style' => true],
941 'h4' => ['class' => true, 'style' => true],
942 'h5' => ['class' => true, 'style' => true],
943 'h6' => ['class' => true, 'style' => true],
944 'blockquote' => ['class' => true, 'style' => true],
945 ];
946 }
947
948 /**
949 * Get allowed protocols for wp_kses sanitization.
950 * Includes 'data' protocol for signature images.
951 *
952 * @return array<int, string>
953 */
954 public static function getAllowedProtocols(): array
955 {
956 return ['http', 'https', 'mailto', 'data'];
957 }
958
959 /**
960 * Sanitize HTML content using wp_kses with support for signature images.
961 *
962 * @param string $content
963 * @return string
964 */
965 public static function sanitizeHtmlContent(string $content): string
966 {
967 return wp_kses($content, self::getAllowedHtmlTags(), self::getAllowedProtocols());
968 }
969
970 /**
971 * Sanitize bulk update request data
972 *
973 * @param array<string, mixed> $data
974 * @return array<string, mixed>
975 */
976 public static function sanitizeBulkUpdateData(array $data): array
977 {
978 $sanitized = [];
979
980 // Sanitize column name (alphanumeric and underscore only)
981 if (isset($data['column'])) {
982 $sanitized['column'] = preg_replace('/[^a-zA-Z0-9_]/', '', $data['column']);
983 }
984
985 // Sanitize value (depends on column)
986 if (isset($data['value'])) {
987 $column = $sanitized['column'] ?? '';
988 if ($column !== '') {
989 if (in_array($column, ['starred', 'status'], true)) {
990 $sanitized['value'] = self::sanitizeEntryColumnValue($column, $data['value']);
991 } else {
992 $sanitized['value'] = self::sanitizeFormColumnValue($column, $data['value']);
993 }
994 } else {
995 $sanitized['value'] = sanitize_text_field($data['value']);
996 }
997 }
998
999 // Sanitize IDs array
1000 if (isset($data['ids']) && is_array($data['ids'])) {
1001 $sanitized['ids'] = array_map('intval', $data['ids']);
1002 $sanitized['ids'] = array_filter($sanitized['ids'], fn($id) => $id > 0);
1003 }
1004
1005 return $sanitized;
1006 }
1007
1008 /**
1009 * Sanitize starred value (convert to boolean integer)
1010 *
1011 * @param mixed $value
1012 * @return int
1013 */
1014 public static function sanitizeStarredValue($value): int
1015 {
1016 return $value ? 1 : 0;
1017 }
1018
1019 /**
1020 * Sanitize entry status value
1021 *
1022 * @param string $status
1023 * @return string
1024 */
1025 public static function sanitizeEntryStatus(string $status): string
1026 {
1027 $allowedStatuses = ['read', 'unread'];
1028 $status = sanitize_text_field($status);
1029 return in_array($status, $allowedStatuses, true) ? $status : 'unread';
1030 }
1031
1032 /**
1033 * Sanitize form column value based on column type
1034 *
1035 * @param string $column
1036 * @param mixed $value
1037 * @return mixed
1038 */
1039 public static function sanitizeFormColumnValue(string $column, $value)
1040 {
1041 // Use FormRepository constants
1042 if ($column === 'starred') {
1043 return self::sanitizeStarredValue($value);
1044 }
1045 return sanitize_text_field($value);
1046 }
1047
1048 /**
1049 * Sanitize entry column value based on column type
1050 *
1051 * @param string $column
1052 * @param mixed $value
1053 * @return mixed
1054 */
1055 public static function sanitizeEntryColumnValue(string $column, $value)
1056 {
1057 // Use EntryRepository constants
1058 if ($column === 'starred') {
1059 return self::sanitizeStarredValue($value);
1060 }
1061 if ($column === 'status') {
1062 return self::sanitizeEntryStatus($value);
1063 }
1064 return sanitize_text_field($value);
1065 }
1066 }
1067