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

639 lines 22.0 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 all form data
24 *
25 * @param mixed[] $data
26 *
27 * @return mixed[]
28 * @throws InvalidArgumentException
29 */
30 public static function sanitizeFormData(array $data): array
31 {
32 $fields = $data['fields'] ?? [];
33 return [
34 'id' => (int)($data['id'] ?? 0),
35 'name' => sanitize_text_field($data['name'] ?? ''),
36 'description' => sanitize_text_field($data['description'] ?? ''),
37 'showTitle' => (bool)($data['showTitle'] ?? false),
38 'published' => (bool)($data['published'] ?? false),
39 'showDescription' => (bool)($data['showDescription'] ?? false),
40 'storeEntries' => (bool)($data['storeEntries'] ?? false),
41 'fields' => self::sanitizeFields($fields),
42 'integrationSettings' => self::sanitizeIntegrationSettings($data['integrationSettings'] ?? [])
43 ];
44 }
45 /**
46 * Sanitize all notification data
47 *
48 * @param mixed[] $data
49 *
50 * @return mixed[]
51 */
52 public static function sanitizeNotificationData(array $data): array
53 {
54 return [
55 'id' => (int)($data['id'] ?? 0),
56 'name' => sanitize_text_field($data['name'] ?? ''),
57 'sender' => self::sanitizeEmailOrPlaceholder($data['sender'] ?? ''),
58 'receiver' => self::sanitizeEmailOrPlaceholder($data['receiver'] ?? ''),
59 'enabled' => (bool)($data['enabled'] ?? true),
60 'subject' => sanitize_text_field($data['subject'] ?? ''),
61 'message' => sanitize_textarea_field($data['message'] ?? ''),
62 'smartLogic' => (bool)($data['smartLogic'] ?? false),
63 'formId' => (int)($data['formId'] ?? 0),
64 ];
65 }
66 /**
67 * Sanitize all confirmation data
68 *
69 * @param mixed[] $data
70 *
71 * @return mixed[]
72 */
73 public static function sanitizeConfirmationData(array $data): array
74 {
75 return [
76 'id' => (int)($data['id'] ?? 0),
77 'formId' => (int)($data['formId'] ?? 0),
78 'type' => sanitize_text_field($data['type'] ?? ''),
79 'enabled' => (bool)($data['enabled'] ?? true),
80 'showForm' => (bool)($data['showForm'] ?? false),
81 'message' => sanitize_textarea_field($data['message'] ?? ''),
82 'url' => sanitize_url(isset($data['url']) ? urldecode($data['url']) : ''),
83 'page' => sanitize_text_field($data['page'] ?? ''),
84 ];
85 }
86
87 /**
88 * Sanitize form fields
89 *
90 * @param mixed[] $fields
91 *
92 * @return mixed[]
93 * @throws InvalidArgumentException
94 */
95 private static function sanitizeFields(array $fields): array
96 {
97 return array_map(function ($field) {
98 // Validate field type
99 $fieldType = sanitize_text_field($field['type'] ?? '');
100 if (!FieldType::isValid($field['type'])) {
101 throw new InvalidArgumentException(
102 BackendStrings::getExceptionStrings()['invalid_field_type']
103 );
104 }
105
106 $sanitizedField = [
107 'id' => (int)($field['id'] ?? 0),
108 'fieldIndex' => (int)($field['fieldIndex'] ?? 0),
109 'type' => $fieldType,
110 'label' => sanitize_text_field($field['label'] ?? ''),
111 'required' => (bool)($field['required'] ?? false),
112 'defaultValue' => sanitize_text_field($field['defaultValue'] ?? ''),
113 'placeholder' => sanitize_text_field($field['placeholder'] ?? ''),
114 'position' => (int)($field['position'] ?? 0),
115 'parentId' => isset($field['parentId']) ? max(0, (int)$field['parentId']) : null,
116 'hideLabel' => (bool)($field['hideLabel'] ?? false),
117 'readOnly' => (bool)($field['readOnly'] ?? false),
118 'description' => sanitize_text_field($field['description'] ?? ''),
119 'requiredMessage' => sanitize_text_field($field['requiredMessage'] ?? ''),
120 'cssClasses' => sanitize_text_field($field['cssClasses'] ?? ''),
121 'fieldOptions' => self::sanitizeFieldOptions($field['fieldOptions'] ?? []),
122 'shuffleOptions' => (bool)($field['shuffleOptions'] ?? false),
123 'showValues' => (bool)($field['showValues'] ?? false),
124 'enableSearch' => (bool)($field['enableSearch'] ?? false),
125 'rows' => (int)($field['rows'] ?? 0),
126 'confirmFieldEnabled' => (bool)($field['confirmFieldEnabled'] ?? false),
127 'confirmFieldLabel' => sanitize_text_field($field['confirmFieldLabel'] ?? ''),
128 'confirmFieldPlaceholder' => sanitize_text_field($field['confirmFieldPlaceholder'] ?? ''),
129 'confirmFieldHideLabel' => (bool)($field['confirmFieldHideLabel'] ?? false),
130 'phoneFormat' => self::sanitizePhoneFormat($field['phoneFormat'] ?? ''),
131 'phoneAutoDetect' => (bool)($field['phoneAutoDetect'] ?? false),
132 'minValue' => self::sanitizeNumericValue($field['minValue'] ?? null),
133 'maxValue' => self::sanitizeNumericValue($field['maxValue'] ?? null),
134 'step' => (float)($field['step'] ?? 1.0),
135 'numberFormat' => sanitize_text_field($field['numberFormat'] ?? ''),
136 'visible' => (bool)($field['visible'] ?? true),
137 'limitMaxLength' => (bool)($field['limitMaxLength'] ?? false),
138 'maxLength' => (int)($field['maxLength'] ?? 255),
139 'customValidationMessage' => sanitize_text_field($field['customValidationMessage'] ?? ''),
140 'labelPosition' => sanitize_text_field($field['labelPosition'] ?? 'default'),
141 'noDuplicates' => (bool)($field['noDuplicates'] ?? false),
142 'inputPrefix' => sanitize_text_field($field['inputPrefix'] ?? ''),
143 'inputSuffix' => sanitize_text_field($field['inputSuffix'] ?? ''),
144 'timeFieldType' => sanitize_text_field($field['timeFieldType'] ?? ''),
145 'timeFormat' => sanitize_text_field($field['timeFormat'] ?? ''),
146 ];
147
148 /**
149 * This filter allows to sanitize form fields properties
150 * for fields that are not part of the core plugin.
151 *
152 * @since 0.1.0
153 *
154 * @param mixed[] $sanitizedField The sanitized field data so far.
155 * @param mixed[] $field The original field data before sanitization.
156 * @return mixed[] The modified sanitized field data.
157 */
158 return apply_filters('ivyforms/sanitizer/field_properties', $sanitizedField, $field);
159 }, $fields);
160 }
161
162 /**
163 * Sanitize field options
164 *
165 * @param mixed[] $options
166 *
167 * @return mixed[]
168 */
169 private static function sanitizeFieldOptions(array $options): array
170 {
171 return array_map(function ($option) {
172 return [
173 'id' => (int)($option['id'] ?? 0),
174 'fieldId' => (int)($option['fieldId'] ?? 0),
175 'label' => sanitize_text_field($option['label'] ?? ''),
176 'value' => sanitize_text_field($option['value'] ?? ''),
177 'isDefault' => (bool)($option['isDefault'] ?? false),
178 'position' => (int)($option['position'] ?? 1),
179 ];
180 }, $options);
181 }
182
183 private static function sanitizePhoneFormat(string $format): string
184 {
185 $allowed = ['international','national','e164'];
186 $formatLower = strtolower($format);
187 return in_array($formatLower, $allowed, true) ? $formatLower : '';
188 }
189
190 /**
191 * Sanitize entry data
192 *
193 * @param mixed[] $data
194 *
195 * @return mixed[]
196 */
197 public static function sanitizeEntryData(array $data): array
198 {
199 return [
200 'id' => (int)($data['id'] ?? 0),
201 'formId' => (int)($data['formId'] ?? 0),
202 'userId' => (int)($data['userId'] ?? 0),
203 'status' => sanitize_text_field($data['status'] ?? ''),
204 'ipAddress' => sanitize_text_field($data['ipAddress'] ?? ''),
205 'userAgent' => sanitize_text_field($data['userAgent'] ?? ''),
206 'sourceURL' => sanitize_text_field($data['sourceURL'] ?? ''),
207 'dateCreated' => sanitize_text_field($data['dateCreated'] ?? ''),
208 'dateEdited' => sanitize_text_field($data['dateEdited'] ?? ''),
209 'starred' => (bool)($data['starred'] ?? false),
210 ];
211 }
212
213 /**
214 * Sanitize ID
215 *
216 * @param int $id
217 *
218 * @return int
219 *
220 * @throws InvalidArgumentException
221 */
222 public static function sanitizeId(int $id): int
223 {
224 if ($id <= 0) {
225 throw new InvalidArgumentException(
226 BackendStrings::getExceptionStrings()['invalid_id']
227 );
228 }
229
230 return $id;
231 }
232
233 /**
234 * Sanitize multiple IDs
235 *
236 * @param int[] $ids
237 *
238 * @return int[]
239 *
240 */
241 public static function sanitizeIds(array $ids): array
242 {
243 return array_map(function ($id) {
244 return (int)$id;
245 }, $ids);
246 }
247
248 /**
249 * Sanitize search parameters
250 *
251 * @param array<string, mixed> $params
252 *
253 * @return array<string, mixed>
254 * @throws InvalidArgumentException
255 */
256 public static function sanitizeSearchParams(array $params): array
257 {
258 $filters = [
259 'starred' => array_key_exists('starred', $params['filters'] ?? [])
260 ? (int)filter_var($params['filters']['starred'], FILTER_VALIDATE_BOOLEAN)
261 : null,
262 'published' => array_key_exists('published', $params['filters'] ?? [])
263 ? (int)filter_var($params['filters']['published'], FILTER_VALIDATE_BOOLEAN)
264 : null,
265 'status' => sanitize_text_field($params['filters']['read'] ?? ''),
266 ];
267 // Add formId to filters if present and valid
268 if (
269 isset($params['filters']['formId']) &&
270 is_numeric($params['filters']['formId']) &&
271 (int)$params['filters']['formId'] > 0
272 ) {
273 $filters['formId'] = self::sanitizeId((int)$params['filters']['formId']);
274 }
275 return [
276 'page' => max((int)($params['page'] ?? 1), 1),
277 'perPage' => max((int)($params['perPage'] ?? 10), 1),
278 'search' => sanitize_text_field($params['search'] ?? ''),
279 'orderBy' => sanitize_text_field($params['orderBy'] ?? 'id'),
280 'order' => strtolower(sanitize_text_field($params['order'] ?? 'asc')) === 'desc' ? 'desc' : 'asc',
281 'filters' => $filters,
282 'dateRange' => [
283 sanitize_text_field($params['dateRange'][0] ?? ''),
284 sanitize_text_field($params['dateRange'][1] ?? ''),
285 ],
286 'shouldGetCount' => (bool)($params['shouldGetCount'] ?? false),
287 'searchFieldValue' => filter_var($params['searchFieldValue'] ?? false, FILTER_VALIDATE_BOOLEAN),
288 ];
289 }
290
291 /**
292 * Sanitize form submission data based on field types and IDs
293 *
294 * @param mixed[] $data Form submission data
295 * @param mixed[] $formFields Form fields configuration
296 *
297 * @return mixed[] Sanitized submission data
298 */
299 public static function sanitizeFormSubmissionData(array $data, array $formFields): array
300 {
301 $sanitizedData = [];
302 list($fieldIdMap, $typeIndexMap) = self::buildFieldMaps($formFields);
303
304 foreach ($data['values'] as $key => $value) {
305 if (self::isInternalField($key)) {
306 $sanitizedData[$key] = $value;
307 continue;
308 }
309 if (isset($fieldIdMap[$key])) {
310 $sanitizedData[$key] = self::sanitizeByFieldType($fieldIdMap[$key], $value);
311 continue;
312 }
313 if (isset($typeIndexMap[$key])) {
314 $fieldId = $typeIndexMap[$key]['id'];
315 $fieldType = $typeIndexMap[$key]['type'];
316 $sanitizedData[$fieldId] = self::sanitizeByFieldType($fieldType, $value);
317 }
318 }
319
320 // Add form ID to sanitized data if present
321 if (isset($data['formId'])) {
322 $sanitizedData['formId'] = (int)$data['formId'];
323 }
324
325 // Add post ID and referer if present (for placeholders)
326 if (isset($data['postId'])) {
327 $sanitizedData['postId'] = (int)($data['postId']);
328 }
329 if (isset($data['referer'])) {
330 $sanitizedData['referer'] = sanitize_text_field($data['referer']);
331 }
332
333 return $sanitizedData;
334 }
335
336 /**
337 * Build field ID and type-index maps from form fields
338 *
339 * @param mixed[] $formFields Form fields configuration
340 * @return mixed[]
341 */
342 private static function buildFieldMaps(array $formFields): array
343 {
344 $fieldIdMap = [];
345 $typeIndexMap = [];
346 foreach ($formFields as $field) {
347 $fieldId = $field->getId() ?? null;
348 $fieldType = $field->getType() ?? null;
349 $fieldIndex = $field->getIndex() ?? null;
350 if ($fieldId !== null) {
351 $fieldIdMap[$fieldId] = $fieldType;
352 if ($fieldType !== null && $fieldIndex !== null) {
353 $typeIndexMap[$fieldType . '_' . $fieldIndex] = [
354 'id' => $fieldId,
355 'type' => $fieldType
356 ];
357 }
358 }
359 }
360 return [$fieldIdMap, $typeIndexMap];
361 }
362
363 /**
364 * Check if a key is an internal field
365 *
366 * @param string $key
367 * @return bool
368 */
369 private static function isInternalField(string $key): bool
370 {
371 return in_array($key, ['nonce', 'formId']);
372 }
373
374 /**
375 * Sanitize a value based on its field type
376 *
377 * @param string $fieldType The type of field
378 * @param mixed $value The value to sanitize
379 *
380 * @return mixed Sanitized value
381 */
382 private static function sanitizeByFieldType(string $fieldType, $value)
383 {
384 // Recursively sanitize arrays/objects (parent-child fields)
385 if (is_array($value)) {
386 return array_map(function ($v) use ($fieldType) {
387 return self::sanitizeByFieldType($fieldType, $v);
388 }, $value);
389 }
390 if (is_object($value)) {
391 $sanitized = array_map(function ($v) use ($fieldType) {
392 return self::sanitizeByFieldType($fieldType, $v);
393 }, get_object_vars($value));
394 return (object)$sanitized;
395 }
396 switch ($fieldType) {
397 case 'email':
398 return sanitize_email($value);
399
400 case 'number':
401 // Preserve decimals
402 return is_numeric($value) ? (float)$value : 0.0;
403
404 case 'phone':
405 // Remove everything except digits, +, and spaces
406 return preg_replace('/[^\d\s+]/', '', $value);
407
408 case 'paragraph':
409 return sanitize_textarea_field($value);
410
411 case 'website':
412 return sanitize_url($value);
413
414 case 'time':
415 $sanitizedTime = preg_replace('/[^0-9: ]/i', '', (string)$value);
416 return sanitize_text_field($sanitizedTime);
417
418 case 'text':
419 default:
420 // Default to text field sanitization
421 return self::sanitizeText($value);
422 }
423 }
424
425 /**
426 * Verify nonce with custom error handling
427 *
428 * @param string|null $nonce
429 *
430 * @throws ForbiddenException
431 */
432 public static function verifyNonce(?string $nonce): void
433 {
434 $nonce = sanitize_text_field($nonce);
435
436 if (!$nonce || !wp_verify_nonce($nonce, 'wp_rest')) {
437 throw new ForbiddenException(
438 BackendStrings::getExceptionStrings()['invalid_nonce']
439 );
440 }
441 }
442 /**
443 * Verify submission nonce with custom error handling
444 *
445 * @param string|null $nonce
446 * @param int $formId
447 *
448 * @throws ForbiddenException
449 */
450 public static function verifySubmissionNonce(?string $nonce, int $formId): void
451 {
452 $nonce = sanitize_text_field($nonce);
453
454 // Verify the form-specific nonce
455 if (!wp_verify_nonce($nonce, 'ivyformsFrontSubmissionNonce_' . $formId)) {
456 throw new ForbiddenException(
457 BackendStrings::getExceptionStrings()['invalid_nonce']
458 );
459 }
460 }
461
462 /**
463 * Sanitize text
464 *
465 * @param string|null $text
466 *
467 * @return string
468 */
469 public static function sanitizeText(?string $text): ?string
470 {
471 if (is_null($text)) {
472 return '';
473 }
474
475 return wp_kses_post(sanitize_text_field($text));
476 }
477
478 /**
479 * Sanitize settings fields
480 *
481 * @param mixed $value
482 *
483 * @return mixed Sanitized value
484 */
485 public static function sanitizeSettingsFields($value)
486 {
487 if (is_array($value)) {
488 return array_map([self::class, 'sanitizeSettingsFields'], $value);
489 }
490
491 if (is_object($value)) {
492 return (object)array_map([self::class, 'sanitizeSettingsFields'], (array)$value);
493 }
494
495 if (is_string($value)) {
496 return self::sanitizeText($value);
497 }
498
499 if (is_int($value)) {
500 return intval($value);
501 }
502
503 if (is_bool($value)) {
504 return filter_var($value, FILTER_VALIDATE_BOOLEAN);
505 }
506
507 return '';
508 }
509
510 /**
511 * Sanitize email or placeholder
512 *
513 * @param string $value
514 *
515 * @return string
516 */
517 public static function sanitizeEmailOrPlaceholder($value): string
518 {
519 $value = trim($value);
520 // Allow placeholders
521 if (preg_match('/^{{[^}]+}}$/', $value)) {
522 return $value;
523 }
524 // Otherwise, sanitize as email
525 return sanitize_email($value);
526 }
527
528 /**
529 * Sanitize numeric values for min/max fields
530 * Convert empty/unselected to null, but allow 0 as a valid value
531 *
532 * @param mixed $value
533 * @return float|null
534 */
535 private static function sanitizeNumericValue($value): ?float
536 {
537 // If value is null or empty string, return null (not selected)
538 if ($value === null || $value === '') {
539 return null;
540 }
541
542 // If value is numeric (including 0), convert to float
543 if (is_numeric($value)) {
544 return (float)$value;
545 }
546
547 // If not numeric, return null
548 return null;
549 }
550
551 /**
552 * Validate email address.
553 *
554 * @param string $email
555 * @return bool
556 */
557 public static function isValidEmail(string $email): bool
558 {
559 return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
560 }
561
562 /**
563 * Normalize field value to string (comma-separated if array)
564 * @param mixed $value
565 * @return string
566 */
567 public static function normalizeFieldValue($value): string
568 {
569 if (is_array($value)) {
570 return implode(', ', $value);
571 }
572 return (string)$value;
573 }
574
575 /**
576 * Sanitize integration settings
577 * Recursively handles nested structures and various data types.
578 *
579 * @param mixed $integrationSettings
580 *
581 * @return array<string, array<string, mixed>>
582 */
583 public static function sanitizeIntegrationSettings($integrationSettings): array
584 {
585 // Handle null or non-array inputs
586 if (!is_array($integrationSettings)) {
587 return [];
588 }
589
590 $cleanedSettings = [];
591
592 foreach ($integrationSettings as $integration => $settings) {
593 // Skip if not a valid integration name or settings structure
594 if (!is_string($integration) || !is_array($settings)) {
595 continue;
596 }
597
598 $cleanedSettings[sanitize_text_field($integration)] = self::sanitizeIntegrationSettingsRecursive($settings);
599 }
600
601 return $cleanedSettings;
602 }
603
604 /**
605 * Recursively sanitize integration settings values
606 *
607 * @param mixed $value
608 * @return mixed
609 */
610 public static function sanitizeIntegrationSettingsRecursive($value)
611 {
612 // Handle arrays recursively
613 if (is_array($value)) {
614 $sanitized = [];
615 foreach ($value as $key => $val) {
616 $sanitizedKey = is_string($key) ? sanitize_text_field($key) : $key;
617 $sanitized[$sanitizedKey] = self::sanitizeIntegrationSettingsRecursive($val);
618 }
619 return $sanitized;
620 }
621
622 // Handle different scalar types
623 if (is_bool($value)) {
624 return $value;
625 }
626 if (is_int($value)) {
627 return $value;
628 }
629 if (is_float($value)) {
630 return $value;
631 }
632 if (is_string($value)) {
633 return sanitize_text_field($value);
634 }
635
636 return '';
637 }
638 }
639